From eef03c5356eb23d722b260fb510e5ae4e5586e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 11:51:53 +0200 Subject: [PATCH 01/13] =?UTF-8?q?docs(eh):=20invoke/landingpad=20experimen?= =?UTF-8?q?t=20=E2=80=94=20Phase=200=20spike=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Personality: Perry-owned Itanium LSDA walker. Thrown value stays in the rooted TLS slot (landing pad ignores {ptr,i32}). js_throw keeps its savepoint restores and raises via _Unwind_RaiseException; js_call_catching keeps its Rust-boundary longjmp trap. Cross-Rust-frame matrix measured: runtime must be panic=abort + force-unwind-tables (longjmp-equivalent, Drops skipped); C-unwind alternative measured and rejected. --- docs/invoke-eh-experiment.md | 177 +++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/invoke-eh-experiment.md diff --git a/docs/invoke-eh-experiment.md b/docs/invoke-eh-experiment.md new file mode 100644 index 0000000000..8e54a44402 --- /dev/null +++ b/docs/invoke-eh-experiment.md @@ -0,0 +1,177 @@ +# Exception lowering: setjmp/longjmp → LLVM `invoke`/`landingpad` + +Status: **in progress** — Phase 0 (spike) complete, Phase 1 (codegen) underway. +Branch: `exp/invoke-eh`. Development flag: `PERRY_EH=invoke|setjmp` (temporary — +deleted when the default flips; a permanent hybrid is the failure mode this +work exists to remove). + +## Why + +Perry lowers `try`/`catch` to `setjmp`/`longjmp` (`perry-codegen/src/stmt/try_stmt.rs`). +That one choice causes three separate problems: + +1. **Precise moving-GC roots are unsound in `try` functions.** A `longjmp` can + jump past a `gc.statepoint`'s `gc.relocate`, so the relocated pointer's + write-back never runs. `exp/stackmap-viability` therefore excludes `has_try` + functions from statepoints and routes them to a plain-stack-map lowering + that is itself unsound (root slots recorded as caller-saved registers, + 3/60 locations on one probe). Under RS4GC it is worse: `mem2reg` cannot + promote the volatile allocas setjmp needs, so try-region roots never enter + SSA and never join a `gc-live` bundle. +2. **~570 lines of machinery exist only to fight the register allocator**: + `volatile_setjmp.rs` (376) + `setjmp_abi.rs` (193) implement C99 7.13.2.1p3 + (values modified between `setjmp` and `longjmp` must be `volatile`). +3. **Every `try` function is pessimized**: `returns_twice` on the setjmp call + plus `#1` (`noinline`) on the whole function are optimization barriers. + +The `invoke`/`landingpad` form makes the unwind edge explicit in the IR: +relocations exist on both the normal and unwind edges, no jump can skip a +write-back, and none of the volatile/noinline machinery is needed. + +## Phase 0 — spike results (macOS arm64, 2026-08-03) + +Standalone probe: hand-written LLVM IR (invoke + landingpad + catch-all) linked +against a small Rust staticlib whose throw path is `_Unwind_RaiseException`. +All scenarios were run under both `panic=unwind` and `panic=abort` builds of +the Rust side. + +### Which personality function? + +**A Perry-specific `perry_eh_personality`**, implemented in `perry-runtime` as +a port of the standard Itanium LSDA walk (same shape as Rust std's +`rust_eh_personality`, which the spike used successfully as a stand-in — it is +class-agnostic and handles `catch ptr null` landing pads for a foreign +exception class). Owning the personality: + +- avoids linking libc++abi (`__gxx_personality_v0`) into every produced binary + and avoids `__cxa_begin_catch`'s foreign-exception edge cases; +- avoids depending on the unstable `rust_eh_personality` symbol's contract; +- is required anyway for the Windows SEH variant, which cannot use the + Itanium personality at all. + +### How does the thrown value map onto the landing pad's `{ ptr, i32 }`? + +**It doesn't need to.** The landing pad ignores both slots. The thrown JS +value stays where it lives today: the GC-rooted TLS `current_exception` slot, +read by `js_get_exception()` / cleared by `js_clear_exception()` — the catch +blocks keep their exact current shape. The `_Unwind_Exception` object is a +per-thread static with class `PERRYJS\0` and a no-op cleanup fn; it carries no +payload. Bit-exactness of NaN-boxed payloads was verified through a full +throw/catch round trip (`0x7ffd000000123456` in → identical bits out). + +### What does `js_throw` become? + +Unchanged until its final line. It still: stores the value into the rooted TLS +slot, checks for the uncaught case, applies the async-context deferred +restores, and restores the shadow-stack / runtime-handle / method-depth / +prototype-resolution / dyn-eval savepoints for the target handler. Then, +instead of `longjmp`: + +- if the innermost open handler is a **generated `try`** → `_Unwind_RaiseException` + on the per-thread exception object. If that returns (`_URC_END_OF_STACK`), + no landing pad existed — report uncaught and exit(1), as today. +- if the innermost open handler is a **Rust-side `js_call_catching` frame** → + `longjmp`, exactly as today. Rust cannot catch a foreign exception + (`catch_unwind` aborts on foreign classes), so the runtime-internal boundary + trap keeps its private `ffi::setjmp`. This is not a second lowering for JS + `try` — no generated code ever emits a setjmp again — it is the JS↔Rust + boundary guard, and it is sound for the same reason it is sound today: the + frames between the throw and the `js_call_catching` frame are *discarded*, + never resumed, and an open `js_call_catching` handler is always innermost + when it is the target (stack order mirrors handler-stack order), so a raise + never crosses an open `js_call_catching` frame. + +Rethrow (`finally` re-raise, catch-with-finally fail path) raises a fresh +exception via `js_throw`; the per-thread object is reusable because the +previous unwind completed when control reached the landing pad. `resume` is +never emitted. + +**Key lowering rule confirmed by the spike:** a rethrow inside a landing-pad +successor must itself be an `invoke` wired to the *enclosing* handler's +landing pad — a plain `call` there sails past every handler in the same +function (the IP is outside all of the LSDA's invoke ranges). In general every +potentially-throwing call must carry the unwind label of the innermost +lexically-enclosing active handler, including inside catch and finally bodies. + +### Phase 2 (answered early): can a throw cross runtime Rust frames? + +Measured, all on the probe (extern "C" helper → interior call → JS callback → +throw; landing pad on the far side of the helper): + +| Rust build | Result | +|---|---| +| `panic=unwind`, helper has an interior Rust call | **process abort** — rustc's abort-on-unwind guard (RFC 2945) fires on the Rust-ABI call site inside the `extern "C"` fn | +| `panic=unwind`, helper calls back through `extern "C"` sites only | caught (no guard on the active path) | +| `panic=unwind`, helper + callback typed `extern "C-unwind"` | caught, and the helper's `Drop` guards **run** during unwind | +| `panic=abort`, default flags | **uncaught / stranded** — rustc omits unwind tables, `_Unwind_RaiseException` cannot step the frame and returns `_URC_END_OF_STACK` | +| `panic=abort` + `-C force-unwind-tables=yes` | **caught, `Drop`s skipped** — exact longjmp-equivalent semantics | + +Decision: **the runtime linked into produced binaries must be built +`panic=abort` with `-C force-unwind-tables=yes`.** + +- It is the only configuration with longjmp-identical semantics, which keeps + `js_throw`'s existing at-throw savepoint restores exactly correct (no Rust + cleanups run behind them — the reason the C-unwind route is dangerous: with + cleanups running *after* the at-throw restore, every skipped guard's `Drop` + would double-restore counters, so all restores would have to move to the + catch side). +- The mass `extern "C-unwind"` alternative also fails closed the wrong way: a + single missed annotation is a production abort discovered only when a throw + first crosses that helper, and it only works under `panic=unwind` (under + `panic=abort`, a C-unwind fn that unwinds aborts by spec — also measured). +- Precedent: the auto-opt library builder already ships feature-stripped + runtimes with `panic=abort` when no `catch_unwind` callers are present + (`perry/src/commands/compile/optimized_libs/driver.rs`). +- Cost: `catch_unwind`-based panic recovery in `perry-runtime/src/thread.rs` + (spawn-worker Rust panics → rejected promise) and + `perry-stdlib/src/worker_threads.rs` stops catching — a runtime *bug* that + panics becomes an abort instead of a rejection. JS exceptions are unaffected + (they never used the panic mechanism). `cargo test` is unaffected (cargo + forces unwind for test builds). +- Enforcement concern (the "gate must assert its subject is live" rule): + `-C force-unwind-tables` rides on RUSTFLAGS/config, and a stray user + `RUSTFLAGS` would silently drop it, stranding every cross-helper throw. + The landed version must carry a self-check (see Phase 1 notes) — e.g. a + runtime `perry_eh_selfcheck()` that performs a real raise across a Rust + frame, exercised by the test harness. + +### Also verified in the spike + +- nested try + rethrow-from-catch to the outer pad +- finally-on-exception-path then re-raise +- uncaught → `_URC_END_OF_STACK` → report + exit(1) +- generated frames without personality are stepped through transparently + +## Windows + +`x86_64-pc-windows-msvc` is a real, CI-exercised target (windows-build job; +doc-tests compile and run TS on windows-2022) and has no +`_Unwind_RaiseException` and no Itanium landing pads. The plan is the SEH +funclet form: `js_throw` → `RaiseException` with a Perry-owned exception code, +`catchswitch`/`catchpad` with personality `__C_specific_handler` and a fixed +filter function matching the code. The invoke-conversion infrastructure in +codegen is shared; only the dispatch/landing shape is per-triple (exactly how +`setjmp_abi` already selects per-triple today). MSVC x64 unwind tables are +mandatory for all functions, so the cross-Rust-frame story has no +force-unwind-tables analogue there. + +## Phase 1+ design notes (running) + +- Handler bookkeeping: `js_try_push` today returns a jmp_buf and the generated + code setjmps on it. Replacement: `js_eh_try_push()` (void) records the same + savepoints and a handler kind (`Generated`); `js_call_catching` pushes kind + `RustCatch` internally with its private jmp_buf. `js_try_end`, catch-side + `js_get_exception` + `js_clear_exception`, return-inside-try `js_try_end` + bookkeeping: all unchanged. +- Codegen chokepoints: every call goes through `LlBlock::{call, call_void, + call_indirect}`; the unwind-label stack lives on the shared `RegCounter` + (same Rc the try-region store tracking uses today). Inside an active + handler scope, calls are emitted as `invoke … to label %eh.cont.N unwind + label %lpad.M` followed by an inline `eh.cont.N:` label line — the LlBlock + keeps appending, so no caller restructuring. Calls to `#2/#3/#4`-attributed + helpers (`nounwind willreturn`) and `@llvm.*` intrinsics stay plain calls. +- `has_try` stops meaning noinline+volatile and starts meaning + `personality ptr @perry_eh_personality` on the define. +- Textual scanners that match `"call "` must learn `invoke` — first found: + `LlBlock::contains_gc_unsafe_call` (#5093 versioned-loop call-free check); + a systematic sweep is part of Phase 1. From 59113fdfdbf4077e09fa3d0855976937eb338b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 12:15:34 +0200 Subject: [PATCH 02/13] feat(eh): invoke/landingpad try lowering behind PERRY_EH=invoke (#7302) Runtime: perry_eh_personality (Itanium LSDA walk, catch-all), per-thread PERRYJS exception object, js_eh_try_push (savepoints, no jmp_buf), js_throw dispatches longjmp vs _Unwind_RaiseException by handler kind. Codegen: EH scope stack on RegCounter; call/call_void/call_indirect emit invoke + inline flush-left continuation label inside active scopes (nothrow-audited helpers, js_shadow_*/js_gc_*, and EH bookkeeping stay plain calls); lower_try_invoke + async-boundary variant reuse the exact catch-entry sequence; personality on define; no #0/#1 attr groups, no volatile pass, no noinline in invoke mode. gc_root_dominance_check.py learns invoke (CALL_RE + CFG edges) so collecting calls inside try bodies stay visible. PERRY_EH participates in the object-cache key (#6394 rule). --- crates/perry-codegen/src/block.rs | 117 ++++- crates/perry-codegen/src/eh_mode.rs | 51 ++ crates/perry-codegen/src/function.rs | 31 +- crates/perry-codegen/src/lib.rs | 1 + crates/perry-codegen/src/module.rs | 17 +- .../src/runtime_decls/strings_part2.rs | 25 +- crates/perry-codegen/src/stmt/mod.rs | 42 +- crates/perry-codegen/src/stmt/try_stmt.rs | 162 +++++++ crates/perry-runtime/src/eh.rs | 446 ++++++++++++++++++ crates/perry-runtime/src/exception.rs | 74 ++- crates/perry-runtime/src/lib.rs | 1 + .../src/commands/compile/object_cache.rs | 6 + scripts/gc_root_dominance_check.py | 16 +- 13 files changed, 941 insertions(+), 48 deletions(-) create mode 100644 crates/perry-codegen/src/eh_mode.rs create mode 100644 crates/perry-runtime/src/eh.rs diff --git a/crates/perry-codegen/src/block.rs b/crates/perry-codegen/src/block.rs index 26614a2ea1..7e01aea944 100644 --- a/crates/perry-codegen/src/block.rs +++ b/crates/perry-codegen/src/block.rs @@ -76,6 +76,15 @@ pub struct RegCounter { /// them into registers that `longjmp` would revert. See /// `crate::volatile_setjmp` for the full argument (#6385). try_region_stores: RefCell>, + /// Invoke-EH mode (#7302): stack of landing-pad labels for the active + /// handler scopes, innermost last. While non-empty, every emitted call + /// that can reach `js_throw` becomes an `invoke` unwinding to the top + /// label (followed by an inline continuation label, so the emitting + /// code keeps appending transparently). Lexical scoping matches the + /// dynamic handler stack: `lower_try` pushes around the try body only — + /// catch/finally bodies see the *enclosing* scope, which is exactly + /// where a throw escaping them lands at runtime. + eh_unwind_labels: RefCell>, } impl RegCounter { @@ -84,9 +93,25 @@ impl RegCounter { value: Cell::new(0), try_region_depth: Cell::new(0), try_region_stores: RefCell::new(HashSet::new()), + eh_unwind_labels: RefCell::new(Vec::new()), } } + /// Enter an invoke-EH handler scope: calls emitted from here until the + /// matching pop unwind to `lpad_label`. + pub fn push_eh_scope(&self, lpad_label: String) { + self.eh_unwind_labels.borrow_mut().push(lpad_label); + } + + pub fn pop_eh_scope(&self) { + self.eh_unwind_labels.borrow_mut().pop(); + } + + /// Landing pad of the innermost active handler scope, if any. + pub fn current_eh_unwind_label(&self) -> Option { + self.eh_unwind_labels.borrow().last().cloned() + } + pub fn next(&self) -> u32 { let v = self.value.get() + 1; self.value.set(v); @@ -179,7 +204,10 @@ impl LlBlock { /// never enter the perry runtime, so they cannot trigger a collection. pub fn contains_gc_unsafe_call(&self) -> bool { self.instructions.iter().any(|line| { - let Some(pos) = line.find("call ") else { + // Invoke-EH (#7302): an `invoke` is a call with an unwind edge — + // it must count here too, or a runtime call inside a `try` would + // silently pass the call-free verification. + let Some(pos) = line.find("call ").or_else(|| line.find("invoke ")) else { return false; }; let callee = &line[pos..]; @@ -225,6 +253,18 @@ impl LlBlock { self.emit(line); } + /// Invoke-EH (#7302): emit the continuation label that follows an + /// `invoke` inline in this block's instruction stream. Flush-left (no + /// two-space instruction indent) so IR-consuming tools that anchor + /// labels at column 0 (`scripts/gc_root_dominance_check.py`'s LABEL_RE) + /// keep parsing the block structure correctly. + fn emit_inline_label(&mut self, label: &str) { + if self.terminated { + return; + } + self.instructions.push(format!("{}:", label)); + } + /// Number of instructions currently in this block. Used by /// `LlFunction::mark_entry_init_boundary` to record where the entry /// block's "prelude" (init calls) ends so post-init hoisted setup @@ -827,6 +867,23 @@ impl LlBlock { // -------- Function calls -------- + /// Invoke-EH (#7302): if a handler scope is active and this callee can + /// reach `js_throw`, the call must carry the scope's unwind edge — + /// otherwise a throw beneath it sails PAST this function's handlers (the + /// IP would sit outside every LSDA call-site range). Returns the + /// `to`/`unwind` suffix and emits nothing; `None` means "emit a plain + /// call". The continuation label is emitted by the caller right after + /// the invoke line — LLVM accepts labels mid-"block" textually, and the + /// LlBlock keeps appending into the continuation transparently. + fn eh_invoke_suffix(&mut self, func_name: &str) -> Option<(String, String)> { + let lpad = self.counter.current_eh_unwind_label()?; + if crate::eh_mode::callee_is_nothrow(func_name) { + return None; + } + let cont = format!("eh.cont{}", self.counter.next()); + Some((cont, lpad)) + } + pub fn call(&mut self, ret_ty: LlvmType, func_name: &str, args: &[(LlvmType, &str)]) -> String { // #835 + #846: record this emission against the FFI provenance // registry. The driver consults the registry after all per-module @@ -834,10 +891,18 @@ impl LlBlock { crate::ext_registry::record_ffi_call(func_name); let r = self.reg(); let arg_str = format_args(args); - self.emit(format!( - "{} = call {} @{}({})", - r, ret_ty, func_name, arg_str - )); + if let Some((cont, lpad)) = self.eh_invoke_suffix(func_name) { + self.emit(format!( + "{} = invoke {} @{}({}) to label %{} unwind label %{}", + r, ret_ty, func_name, arg_str, cont, lpad + )); + self.emit_inline_label(&cont); + } else { + self.emit(format!( + "{} = call {} @{}({})", + r, ret_ty, func_name, arg_str + )); + } r } @@ -845,7 +910,15 @@ impl LlBlock { // #835 + #846: same registry hook as `call` — see comment there. crate::ext_registry::record_ffi_call(func_name); let arg_str = format_args(args); - self.emit(format!("call void @{}({})", func_name, arg_str)); + if let Some((cont, lpad)) = self.eh_invoke_suffix(func_name) { + self.emit(format!( + "invoke void @{}({}) to label %{} unwind label %{}", + func_name, arg_str, cont, lpad + )); + self.emit_inline_label(&cont); + } else { + self.emit(format!("call void @{}({})", func_name, arg_str)); + } } /// Empty inline-asm barrier (`call void asm sideeffect "", ""()`). @@ -869,14 +942,30 @@ impl LlBlock { let r = self.reg(); let arg_str = format_args(args); let param_types: Vec<&str> = args.iter().map(|(t, _)| *t).collect(); - self.emit(format!( - "{} = call {} ({})* {}({})", - r, - ret_ty, - param_types.join(", "), - fn_ptr, - arg_str - )); + // Indirect targets (closures, method pointers) can always throw. + if let Some(lpad) = self.counter.current_eh_unwind_label() { + let cont = format!("eh.cont{}", self.counter.next()); + self.emit(format!( + "{} = invoke {} ({})* {}({}) to label %{} unwind label %{}", + r, + ret_ty, + param_types.join(", "), + fn_ptr, + arg_str, + cont, + lpad + )); + self.emit_inline_label(&cont); + } else { + self.emit(format!( + "{} = call {} ({})* {}({})", + r, + ret_ty, + param_types.join(", "), + fn_ptr, + arg_str + )); + } r } diff --git a/crates/perry-codegen/src/eh_mode.rs b/crates/perry-codegen/src/eh_mode.rs new file mode 100644 index 0000000000..8b38d7fa62 --- /dev/null +++ b/crates/perry-codegen/src/eh_mode.rs @@ -0,0 +1,51 @@ +//! TEMPORARY exception-lowering mode switch (#7302). +//! +//! `PERRY_EH=invoke` lowers `try`/`catch` (and the async rejection boundary) +//! to `invoke`/`landingpad` with `js_throw` raising through the Itanium +//! unwinder; unset / `PERRY_EH=setjmp` keeps the setjmp/longjmp lowering. +//! +//! This flag exists ONLY for bisection while the invoke lowering is +//! validated. It is deleted — together with the entire setjmp path +//! (`volatile_setjmp.rs`, `setjmp_abi.rs`, `returns_twice`/`#1` handling) — +//! when the default flips. A permanent hybrid is the failure mode #7302 +//! exists to remove; do not build on this switch. +//! +//! The value participates in the object-cache key +//! (`perry/src/commands/compile/object_cache.rs`) — the two modes emit +//! structurally different IR for every function containing a `try`. + +use std::sync::OnceLock; + +pub(crate) fn invoke_eh_enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| { + matches!( + std::env::var("PERRY_EH").as_deref(), + Ok("invoke") | Ok("INVOKE") | Ok("1") + ) + }) +} + +/// Runtime helpers that participate in the EH machinery itself and are +/// verified never to reach `js_throw` — they stay plain `call`s inside a +/// protected region (an `invoke` on them would be legal but wires the EH +/// bookkeeping into its own landing pads, which is both noise and, for the +/// catch-entry sequence, a self-referential shape). +/// +/// `js_shadow_*` (GC shadow-stack bookkeeping: TLS pushes/pops/stores) and +/// `js_gc_*` (collection entry points) cannot throw JS by construction — +/// the GC has no throw path; allocation failure is a Rust abort. +pub(crate) fn callee_is_nothrow(name: &str) -> bool { + name.starts_with("llvm.") + || name.starts_with("js_shadow_") + || name.starts_with("js_gc_") + || matches!( + name, + "js_eh_try_push" + | "js_try_end" + | "js_get_exception" + | "js_clear_exception" + | "js_has_exception" + ) + || !crate::module::helper_decl_attrs(name).is_empty() +} diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index 5f55a76c45..8cfce6660d 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -48,6 +48,13 @@ pub struct LlFunction { /// already implies the hint, so the two are never emitted together, and /// `has_try` (noinline) still wins over both in `to_ir`. pub inline_hint: bool, + /// Invoke-EH (#7302): this function contains `landingpad` instructions, + /// so its `define` line must carry + /// `personality ptr @perry_eh_personality`. Set by the invoke-mode + /// try/async-boundary lowering; orthogonal to `has_try` (which drives + /// the setjmp-era noinline/volatile machinery and stays false in invoke + /// mode). + pub needs_personality: bool, blocks: Vec, block_counter: u32, reg_counter: Rc, @@ -214,6 +221,7 @@ impl LlFunction { has_try: false, force_inline: false, inline_hint: false, + needs_personality: false, blocks: Vec::new(), block_counter: 0, reg_counter: Rc::new(RegCounter::new()), @@ -412,6 +420,17 @@ impl LlFunction { self.reg_counter.exit_try_region(); } + /// Invoke-EH (#7302): enter/leave a handler scope. While a scope is + /// active, every potentially-throwing call any block of this function + /// emits carries an unwind edge to the scope's landing-pad label. + pub fn push_eh_scope(&self, lpad_label: String) { + self.reg_counter.push_eh_scope(lpad_label); + } + + pub fn pop_eh_scope(&self) { + self.reg_counter.pop_eh_scope(); + } + /// Allocate a fresh stack slot in the function entry block. Returns /// the SSA pointer name (e.g. `%r42`). The instruction is emitted at /// the top of block 0, ahead of any existing entry-block code, so @@ -612,9 +631,17 @@ impl LlFunction { } else { "" }; + // Invoke-EH (#7302): functions containing landing pads name their + // personality on the define line (LLVM: `define ... [fn attrs] + // [personality] { ... }`). + let personality = if self.needs_personality { + " personality ptr @perry_eh_personality" + } else { + "" + }; let mut ir = format!( - "define {}{} @{}({}){} {{\n", - linkage, self.return_type, self.name, param_str, attrs + "define {}{} @{}({}){}{} {{\n", + linkage, self.return_type, self.name, param_str, attrs, personality ); for (i, blk) in self.blocks.iter().enumerate() { diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 6d87dde4fe..9a942189de 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -8,6 +8,7 @@ pub mod block; pub(crate) mod boxed_vars; pub mod codegen; pub(crate) mod collectors; +pub(crate) mod eh_mode; pub mod expr; pub mod ext_registry; pub mod function; diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index 297d1642f7..ae54a4c1e6 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -109,7 +109,7 @@ fn promote_global_for_units(line: &str) -> String { /// string/object paths read+parse and reach ToPrimitive), /// `js_value_length_f64` (Buffer/TypedArray registry lookups take locks — /// a lock acquisition writes memory). -fn helper_decl_attrs(name: &str) -> &'static str { +pub(crate) fn helper_decl_attrs(name: &str) -> &'static str { match name { // PURE — each verified: pure bit tests/masking on the f64/i64 args, // total over arbitrary bits, no memory access anywhere in the body. @@ -277,6 +277,21 @@ impl LlModule { )); } + /// Invoke-EH (#7302): declare the personality routine referenced by + /// every `define ... personality ptr @perry_eh_personality`. Declared + /// varargs — the symbol is only ever *named* on define lines and in the + /// unwind tables; generated code never calls it. + pub fn declare_personality(&mut self) { + if self.declared_names.contains("perry_eh_personality") { + return; + } + self.declared_names.insert("perry_eh_personality".to_string()); + self.declarations.push(( + "perry_eh_personality".to_string(), + "declare i32 @perry_eh_personality(...)".to_string(), + )); + } + /// [`Self::declare_function`] with LLVM *return* parameter attributes /// (`nonnull`, `noalias`, …), which sit before the return type and so /// cannot be expressed through the trailing attribute-group string. diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 6fd8e9d4f5..e82e51e53e 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -727,15 +727,22 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { // js_clear_exception() resets the exception state. // js_has_exception() returns i32 (1 if exception is active, 0 otherwise). // js_enter_finally() / js_leave_finally() bracket finally blocks. - module.declare_function("js_try_push", PTR, &[]); - // setjmp variant selection: decided by `crate::setjmp_abi` from the - // compile target's LLVM triple (`module.target_triple`), NOT host - // `cfg!` — cross-compiles must declare the *target's* setjmp ABI - // (Windows MSVC 2-arg `_setjmp`, Apple fast 1-arg `_setjmp`, plain - // `setjmp` elsewhere; full rationale in `crate::setjmp_abi`). The - // same `SetjmpAbi` drives the call sites in `stmt/try_stmt.rs`, so - // the declaration and the calls can never disagree on name or arity. - { + if crate::eh_mode::invoke_eh_enabled() { + // Invoke-EH (#7302): handlers are armed by js_eh_try_push (savepoints + // only, no jmp_buf) and entered through landing pads; the personality + // is named on every try-containing define line. No setjmp is declared + // — which also keeps the `#0`/`#1` attribute groups out of the module. + module.declare_function("js_eh_try_push", VOID, &[]); + module.declare_personality(); + } else { + module.declare_function("js_try_push", PTR, &[]); + // setjmp variant selection: decided by `crate::setjmp_abi` from the + // compile target's LLVM triple (`module.target_triple`), NOT host + // `cfg!` — cross-compiles must declare the *target's* setjmp ABI + // (Windows MSVC 2-arg `_setjmp`, Apple fast 1-arg `_setjmp`, plain + // `setjmp` elsewhere; full rationale in `crate::setjmp_abi`). The + // same `SetjmpAbi` drives the call sites in `stmt/try_stmt.rs`, so + // the declaration and the calls can never disagree on name or arity. let abi = crate::setjmp_abi::setjmp_abi_for_triple(&module.target_triple); module.declare_function(abi.callee(), I32, abi.param_types()); } diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index ae65b69164..de6b42cf4d 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -83,7 +83,7 @@ fn lower_async_rejecting_stmts_inner( // machines still need the ECMAScript async boundary: any abrupt // completion before the first await rejects the returned Promise instead // of escaping as a host exception. - ctx.func.has_try = true; + let invoke_eh = crate::eh_mode::invoke_eh_enabled(); let body_idx = ctx.new_block("async.body"); let catch_idx = ctx.new_block("async.catch"); @@ -93,25 +93,39 @@ fn lower_async_rejecting_stmts_inner( let catch_label = ctx.block_label(catch_idx); let merge_label = ctx.block_label(merge_idx); - // js_try_push + target-ABI setjmp + branch — shared with `lower_try` so - // the setjmp variant (chosen from `ctx.target_triple`, see - // `crate::setjmp_abi`) is decided in exactly one place. - try_stmt::emit_setjmp_dispatch(ctx, &catch_label, &body_label); + // Handler dispatch — shared with `lower_try` so the mechanism (invoke + // landing pad, or the target-ABI setjmp variant chosen from + // `ctx.target_triple` via `crate::setjmp_abi`) is decided in exactly one + // place per mode. + let eh_scope = if invoke_eh { + Some(try_stmt::emit_eh_dispatch(ctx, &catch_label, &body_label)) + } else { + ctx.func.has_try = true; + try_stmt::emit_setjmp_dispatch(ctx, &catch_label, &body_label); + None + }; ctx.current_block = body_idx; ctx.try_depth += 1; - // The whole async body runs between the setjmp above and a possible - // longjmp into `async.catch`. `async.catch` itself only touches runtime - // state (get/clear exception, reject the promise) and never reads a - // local, so in principle no alloca needs to survive that longjmp — but we - // open the region anyway rather than special-case it. The uniform rule + // Setjmp mode: the whole async body runs between the setjmp above and a + // possible longjmp into `async.catch`. `async.catch` itself only touches + // runtime state (get/clear exception, reject the promise) and never reads + // a local, so in principle no alloca needs to survive that longjmp — but + // we open the region anyway rather than special-case it. The uniform rule // ("every alloca stored inside a setjmp-protected region is volatile") is // the one that is trivially sound, and this is still strictly better than - // the `optnone` it replaces: the arithmetic, compares and branches in an - // async body now optimize even though its locals stay frame-resident. - ctx.func.enter_try_region(); + // the `optnone` it replaces. Invoke mode needs none of that: the landing + // pad reads no locals, and the unwind edges keep SSA values live where + // LLVM's ordinary EH liveness says so. + match &eh_scope { + Some(lpad) => ctx.func.push_eh_scope(lpad.clone()), + None => ctx.func.enter_try_region(), + } lower_stmts_inner(ctx, stmts, emit_shadow_clears)?; - ctx.func.exit_try_region(); + match &eh_scope { + Some(_) => ctx.func.pop_eh_scope(), + None => ctx.func.exit_try_region(), + } ctx.try_depth -= 1; if !ctx.block().is_terminated() { ctx.block().call_void("js_try_end", &[]); diff --git a/crates/perry-codegen/src/stmt/try_stmt.rs b/crates/perry-codegen/src/stmt/try_stmt.rs index 50ca13b74c..491f24b757 100644 --- a/crates/perry-codegen/src/stmt/try_stmt.rs +++ b/crates/perry-codegen/src/stmt/try_stmt.rs @@ -47,12 +47,50 @@ pub(super) fn emit_setjmp_dispatch(ctx: &mut FnCtx<'_>, exc_label: &str, normal_ blk.cond_br(&is_exc, exc_label, normal_label); } +/// Invoke-EH (#7302) counterpart of [`emit_setjmp_dispatch`]: arm the +/// handler (`js_eh_try_push` — savepoints only, no jmp_buf), branch into +/// the protected body, and materialize the landing-pad block that funnels +/// the unwinder into `exc_label`. Returns the landing pad's label; the +/// caller pushes it as the EH scope around the protected body so every +/// potentially-throwing call inside carries the unwind edge. +/// +/// The landing pad ignores the `{ ptr, i32 }` pair — the thrown value is +/// read back from the runtime's rooted TLS slot via `js_get_exception`, +/// exactly as the setjmp path does. Savepoint restores already ran at +/// throw time (`js_throw`), which is sound because the unwinder skips +/// Rust cleanups just like `longjmp` did (runtime built panic=abort; see +/// `perry-runtime/src/eh.rs`). +pub(super) fn emit_eh_dispatch( + ctx: &mut FnCtx<'_>, + exc_label: &str, + normal_label: &str, +) -> String { + ctx.func.needs_personality = true; + let lpad_idx = ctx.new_block("eh.lpad"); + let lpad_label = ctx.block_label(lpad_idx); + + ctx.block().call_void("js_eh_try_push", &[]); + ctx.block().br(normal_label); + + let saved = ctx.current_block; + ctx.current_block = lpad_idx; + let lp = ctx.block().next_reg(); + ctx.block() + .emit_raw(format!("{} = landingpad {{ ptr, i32 }} catch ptr null", lp)); + ctx.block().br(exc_label); + ctx.current_block = saved; + lpad_label +} + pub(crate) fn lower_try( ctx: &mut FnCtx<'_>, body: &[perry_hir::Stmt], catch: Option<&perry_hir::CatchClause>, finally: Option<&[perry_hir::Stmt]>, ) -> Result<()> { + if crate::eh_mode::invoke_eh_enabled() { + return lower_try_invoke(ctx, body, catch, finally); + } // Mark the enclosing function so IR emission adds `#1` (noinline) and // runs the setjmp volatile-promotion pass. // @@ -217,3 +255,127 @@ pub(crate) fn lower_try( } Ok(()) } + +/// Invoke-EH lowering of `Stmt::Try` (#7302). Structurally the same CFG as +/// the setjmp version — the differences are the transport, not the shape: +/// +/// 1. `js_eh_try_push()` arms the handler (savepoints, no jmp_buf) and the +/// body is entered by a plain branch — no setjmp, no `returns_twice`, +/// no volatile promotion, no `noinline`. +/// 2. While the body lowers, its landing-pad label is the active EH scope: +/// every potentially-throwing call becomes an `invoke` unwinding there. +/// 3. The landing pad funnels into the same catch-entry sequence the +/// setjmp path used (`js_try_end` → `js_get_exception` → +/// `js_clear_exception`). +/// 4. Catch/finally bodies lower under the *enclosing* scope (the inner +/// scope is popped first), so a throw escaping them wires to the outer +/// handler — or leaves the function entirely when there is none. The +/// re-raise sites (`js_throw` after a finally copy) go through the same +/// chokepoint and pick up the correct edge automatically. +pub(crate) fn lower_try_invoke( + ctx: &mut FnCtx<'_>, + body: &[perry_hir::Stmt], + catch: Option<&perry_hir::CatchClause>, + finally: Option<&[perry_hir::Stmt]>, +) -> Result<()> { + let try_body_idx = ctx.new_block("try.body"); + let catch_idx = ctx.new_block("try.catch"); + let finally_idx = ctx.new_block("try.finally"); + + let try_body_label = ctx.block_label(try_body_idx); + let catch_label = ctx.block_label(catch_idx); + let finally_label = ctx.block_label(finally_idx); + + // --- current block: arm handler, enter body; landing pad → catch --- + let lpad_label = emit_eh_dispatch(ctx, &catch_label, &try_body_label); + + // --- try body (scope active) --- + ctx.current_block = try_body_idx; + // Return/break/continue inside the body pop the handler via js_try_end + // before leaving — same bookkeeping as the setjmp path. + ctx.try_depth += 1; + ctx.func.push_eh_scope(lpad_label); + lower_stmts(ctx, body)?; + ctx.func.pop_eh_scope(); + ctx.try_depth -= 1; + if !ctx.block().is_terminated() { + ctx.block().call_void("js_try_end", &[]); + ctx.block().br(&finally_label); + } + + // --- catch (reached only through the landing pad) --- + ctx.current_block = catch_idx; + ctx.block().call_void("js_try_end", &[]); + if let Some(clause) = catch { + let exc = ctx.block().call(DOUBLE, "js_get_exception", &[]); + ctx.block().call_void("js_clear_exception", &[]); + if let Some((id, _name)) = &clause.param { + // Entry-block slot + shadow-slot bind: identical to the setjmp + // path (#7209 — after js_clear_exception this alloca is the only + // root keeping the exception alive, and the bind must follow the + // store so the root-word decoder never sees uninitialized bytes). + let slot = ctx.func.alloca_entry(DOUBLE); + ctx.locals.insert(*id, slot.clone()); + ctx.block().store(DOUBLE, &exc, &slot); + crate::expr::emit_shadow_slot_bind_for_local(ctx, *id); + } + if let Some(f) = finally { + // Spec: a throw escaping the CATCH body must still run the + // finally, whose own abrupt completion replaces the pending one. + // Protect the catch body with its own handler; its landing pad + // runs a dedicated finally copy and re-raises. + // Refs test262 S12.14_A7_T2/T3, S12.14_A13_T3. + let cbody_idx = ctx.new_block("try.catch.body"); + let cfail_idx = ctx.new_block("try.catch.fail"); + let cbody_label = ctx.block_label(cbody_idx); + let cfail_label = ctx.block_label(cfail_idx); + let cfail_lpad = emit_eh_dispatch(ctx, &cfail_label, &cbody_label); + + ctx.current_block = cbody_idx; + ctx.try_depth += 1; + ctx.func.push_eh_scope(cfail_lpad); + lower_stmts(ctx, &clause.body)?; + ctx.func.pop_eh_scope(); + ctx.try_depth -= 1; + if !ctx.block().is_terminated() { + ctx.block().call_void("js_try_end", &[]); + ctx.block().br(&finally_label); + } + + ctx.current_block = cfail_idx; + ctx.block().call_void("js_try_end", &[]); + let exc2 = ctx.block().call(DOUBLE, "js_get_exception", &[]); + lower_stmts(ctx, f)?; + if !ctx.block().is_terminated() { + ctx.block().call_void("js_throw", &[(DOUBLE, &exc2)]); + ctx.block().unreachable(); + } + } else { + lower_stmts(ctx, &clause.body)?; + if !ctx.block().is_terminated() { + ctx.block().br(&finally_label); + } + } + } else { + // try/finally with no catch: run the finally copy on the exception + // path, then RE-RAISE the original exception (it must not be + // swallowed — issue #37). Capture before the finally body, which may + // touch exception state; a `return`/`throw` inside the finally + // overrides the pending exception per spec (its terminator stands). + let exc = ctx.block().call(DOUBLE, "js_get_exception", &[]); + if let Some(f) = finally { + lower_stmts(ctx, f)?; + } + if !ctx.block().is_terminated() { + ctx.block().call_void("js_throw", &[(DOUBLE, &exc)]); + ctx.block().unreachable(); + } + } + + // --- finally / merge (normal-completion path) --- + ctx.current_block = finally_idx; + if let Some(f) = finally { + lower_stmts(ctx, f)?; + } + Ok(()) +} diff --git a/crates/perry-runtime/src/eh.rs b/crates/perry-runtime/src/eh.rs new file mode 100644 index 0000000000..acd50a7bcf --- /dev/null +++ b/crates/perry-runtime/src/eh.rs @@ -0,0 +1,446 @@ +//! Itanium-ABI exception transport for `try`/`catch` (`invoke`/`landingpad`). +//! +//! Replaces the `longjmp` transport for generated-code `try` handlers (#7302): +//! `js_throw` stores the thrown JS value in the GC-rooted TLS slot exactly as +//! before, then raises a payload-free `_Unwind_Exception` with class +//! `PERRYJS\0`. Generated functions containing `try` carry +//! `personality ptr @perry_eh_personality` and a `landingpad {ptr,i32} +//! catch ptr null` per handler; the personality below walks the LSDA and +//! transfers control there. The landing pad ignores the `{ptr,i32}` pair — +//! the value is read back via `js_get_exception()`, unchanged. +//! +//! The unwinder steps *through* runtime Rust frames without running any +//! cleanup (the runtime is built `panic=abort` + forced unwind tables — see +//! `docs/invoke-eh-experiment.md`), which is exactly the `longjmp` semantics +//! the savepoint-restore system in `exception.rs` was built for. Rust-side +//! catches (`js_call_catching`) never see a raise at all: an open Rust +//! handler is always innermost when it is the throw target, and `js_throw` +//! uses its private `longjmp` for those (see `HandlerKind`). +//! +//! The personality routine and LSDA walk are a port of Rust std's +//! `rust_eh_personality` / `sys::personality::dwarf` (MIT OR Apache-2.0), +//! trimmed to the encodings LLVM emits for Perry's targets and with the +//! type-table/filter logic dropped (Perry landing pads are always +//! `catch ptr null` — catch-all; there are no cleanups and no filters in +//! generated code). + +#![allow(non_upper_case_globals)] + +use core::ffi::c_int; + +// --------------------------------------------------------------------------- +// Minimal libunwind / libgcc Itanium unwind API bindings. +// --------------------------------------------------------------------------- + +pub(crate) type UnwindReasonCode = c_int; +pub(crate) const _URC_HANDLER_FOUND: UnwindReasonCode = 6; +pub(crate) const _URC_INSTALL_CONTEXT: UnwindReasonCode = 7; +pub(crate) const _URC_CONTINUE_UNWIND: UnwindReasonCode = 8; +pub(crate) const _URC_FATAL_PHASE1_ERROR: UnwindReasonCode = 3; + +type UnwindAction = c_int; +const _UA_SEARCH_PHASE: UnwindAction = 1; + +#[repr(C)] +pub struct UnwindException { + pub class: u64, + pub cleanup: Option, + // The SysV/Itanium header reserves 2 private words; some ports scribble + // on more. Over-sizing is harmless — the unwinder only uses its own view. + pub private: [usize; 6], +} + +// An opaque unwind context handle passed to the personality routine. +#[repr(C)] +pub struct UnwindContext { + _opaque: [u8; 0], +} + +extern "C" { + /// Returns only on failure (`_URC_END_OF_STACK` when no handler exists). + fn _Unwind_RaiseException(exception: *mut UnwindException) -> UnwindReasonCode; + fn _Unwind_GetLanguageSpecificData(ctx: *mut UnwindContext) -> *const u8; + fn _Unwind_GetIPInfo(ctx: *mut UnwindContext, ip_before_insn: *mut c_int) -> usize; + fn _Unwind_GetRegionStart(ctx: *mut UnwindContext) -> usize; + fn _Unwind_SetGR(ctx: *mut UnwindContext, reg_index: c_int, value: usize); + fn _Unwind_SetIP(ctx: *mut UnwindContext, value: usize); +} + +// DWARF register numbers for the exception-pointer / exception-selector +// registers the landing pad reads (LLVM TargetLowering::getException*Register). +#[cfg(target_arch = "x86_64")] +const UNWIND_DATA_REG: (c_int, c_int) = (0, 1); // RAX, RDX +#[cfg(any(target_arch = "arm", target_arch = "aarch64"))] +const UNWIND_DATA_REG: (c_int, c_int) = (0, 1); // R0/X0, R1/X1 +#[cfg(target_arch = "x86")] +const UNWIND_DATA_REG: (c_int, c_int) = (0, 2); // EAX, EDX + +/// `PERRYJS\0` — vendor-tagged exception class. The personality is +/// class-agnostic (every Perry landing pad is a catch-all), but the tag keeps +/// Perry exceptions distinguishable from C++/Rust ones in a debugger and lets +/// a future mixed-runtime personality discriminate. +pub const PERRY_EXCEPTION_CLASS: u64 = u64::from_be_bytes(*b"PERRYJS\0"); + +extern "C" fn perry_exception_cleanup(_reason: UnwindReasonCode, _exc: *mut UnwindException) { + // Per-thread static object, payload lives in the TLS exception slot: + // nothing to free. Reached only if foreign code deletes our exception. +} + +thread_local! { + static EXC_OBJECT: std::cell::UnsafeCell = + const { + std::cell::UnsafeCell::new(UnwindException { + class: PERRY_EXCEPTION_CLASS, + cleanup: Some(perry_exception_cleanup), + private: [0; 6], + }) + }; +} + +/// Raise the per-thread Perry exception. Returns ONLY if the unwinder found +/// no handler (the caller reports the uncaught exception and exits) — with a +/// handler-stack entry present this indicates lost unwind tables between the +/// throw point and the handler frame (e.g. a stray `RUSTFLAGS` dropped +/// `-C force-unwind-tables` from the runtime build), which the caller must +/// report loudly rather than mask. +pub(crate) fn raise_perry_exception() -> UnwindReasonCode { + let exc = EXC_OBJECT.with(|c| c.get()); + unsafe { + // Re-arm the header on every raise: the unwinder scribbles on the + // private words, and a rethrow-from-catch reuses this object (legal: + // the previous unwind completed when control reached the pad). + (*exc).class = PERRY_EXCEPTION_CLASS; + (*exc).cleanup = Some(perry_exception_cleanup); + (*exc).private = [0; 6]; + _Unwind_RaiseException(exc) + } +} + +// --------------------------------------------------------------------------- +// Personality routine. +// --------------------------------------------------------------------------- + +/// The personality for Perry-generated functions (Itanium two-phase model). +/// +/// Search phase: report `HANDLER_FOUND` iff the current IP sits inside a +/// call-site range with a landing pad (Perry pads are all catch-all handlers). +/// Cleanup phase: install the landing pad. IPs outside every range mean the +/// active call site was not `invoke`-protected — continue unwinding (that is +/// the deliberate semantic for throws escaping a frame with no enclosing +/// `try`; the C++ personality would `terminate` here instead). +/// +/// # Safety +/// Called by the system unwinder with a live unwind context. +#[no_mangle] +pub unsafe extern "C" fn perry_eh_personality( + version: c_int, + actions: UnwindAction, + _exception_class: u64, + exception_object: *mut UnwindException, + context: *mut UnwindContext, +) -> UnwindReasonCode { + if version != 1 { + return _URC_FATAL_PHASE1_ERROR; + } + let lpad = match find_landing_pad(context) { + Ok(l) => l, + Err(()) => return _URC_FATAL_PHASE1_ERROR, + }; + if actions & _UA_SEARCH_PHASE != 0 { + match lpad { + Some(_) => _URC_HANDLER_FOUND, + None => _URC_CONTINUE_UNWIND, + } + } else { + match lpad { + Some(lpad) => { + _Unwind_SetGR(context, UNWIND_DATA_REG.0, exception_object as usize); + _Unwind_SetGR(context, UNWIND_DATA_REG.1, 0); + _Unwind_SetIP(context, lpad); + _URC_INSTALL_CONTEXT + } + None => _URC_CONTINUE_UNWIND, + } + } +} + +/// LSDA walk: map the frame's current IP to its landing pad, if any. +unsafe fn find_landing_pad(context: *mut UnwindContext) -> Result, ()> { + let lsda = _Unwind_GetLanguageSpecificData(context); + if lsda.is_null() { + return Ok(None); + } + let mut ip_before_insn: c_int = 0; + let ip = _Unwind_GetIPInfo(context, &mut ip_before_insn); + // The return address points one byte past the call instruction, which + // could fall into the next call-site range. + let ip = if ip_before_insn != 0 { + ip + } else { + ip.wrapping_sub(1) + }; + let func_start = _Unwind_GetRegionStart(context); + find_landing_pad_in_lsda(lsda, ip, func_start) +} + +/// The GCC-style LSDA layout: header (landing-pad base encoding + optional +/// base, type-table encoding + optional offset, call-site encoding), then the +/// call-site table sorted by start offset. Perry generates only catch-all +/// handlers, so the action/type tables need no interpretation: any non-zero +/// landing-pad offset is a handler. +unsafe fn find_landing_pad_in_lsda( + lsda: *const u8, + ip: usize, + func_start: usize, +) -> Result, ()> { + let mut reader = DwarfReader::new(lsda); + + let start_encoding = reader.read_u8(); + let lpad_base = if start_encoding != DW_EH_PE_omit { + read_encoded_pointer(&mut reader, start_encoding, func_start)? + } else { + func_start + }; + + let ttype_encoding = reader.read_u8(); + if ttype_encoding != DW_EH_PE_omit { + // Class-info offset — skipped, we never inspect the type table. + reader.read_uleb128(); + } + + let call_site_encoding = reader.read_u8(); + let call_site_table_length = reader.read_uleb128(); + let action_table = reader.ptr.add(call_site_table_length as usize); + + while reader.ptr < action_table { + let cs_start = read_encoded_offset(&mut reader, call_site_encoding)?; + let cs_len = read_encoded_offset(&mut reader, call_site_encoding)?; + let cs_lpad = read_encoded_offset(&mut reader, call_site_encoding)?; + let _cs_action = reader.read_uleb128(); + // Sorted by cs_start: once past the ip, stop. + if ip < func_start.wrapping_add(cs_start) { + break; + } + if ip < func_start.wrapping_add(cs_start + cs_len) { + return Ok(if cs_lpad == 0 { + None + } else { + Some(lpad_base.wrapping_add(cs_lpad)) + }); + } + } + // IP not in the table: a non-invoke call site — no handler in this frame. + Ok(None) +} + +// --------------------------------------------------------------------------- +// DWARF exception-header encoded values (LSB spec, "dwarfext"). +// --------------------------------------------------------------------------- + +const DW_EH_PE_omit: u8 = 0xFF; +const DW_EH_PE_absptr: u8 = 0x00; +const DW_EH_PE_uleb128: u8 = 0x01; +const DW_EH_PE_udata2: u8 = 0x02; +const DW_EH_PE_udata4: u8 = 0x03; +const DW_EH_PE_udata8: u8 = 0x04; +const DW_EH_PE_sleb128: u8 = 0x09; +const DW_EH_PE_sdata2: u8 = 0x0A; +const DW_EH_PE_sdata4: u8 = 0x0B; +const DW_EH_PE_sdata8: u8 = 0x0C; +const DW_EH_PE_pcrel: u8 = 0x10; +const DW_EH_PE_indirect: u8 = 0x80; + +struct DwarfReader { + ptr: *const u8, +} + +impl DwarfReader { + fn new(ptr: *const u8) -> Self { + DwarfReader { ptr } + } + + unsafe fn read_u8(&mut self) -> u8 { + let v = *self.ptr; + self.ptr = self.ptr.add(1); + v + } + + unsafe fn read_unaligned(&mut self) -> T { + let v = (self.ptr as *const T).read_unaligned(); + self.ptr = self.ptr.add(core::mem::size_of::()); + v + } + + unsafe fn read_uleb128(&mut self) -> u64 { + let mut result: u64 = 0; + let mut shift: u32 = 0; + loop { + let byte = self.read_u8(); + result |= u64::from(byte & 0x7F) << shift; + shift += 7; + if byte & 0x80 == 0 { + return result; + } + } + } + + unsafe fn read_sleb128(&mut self) -> i64 { + let mut result: u64 = 0; + let mut shift: u32 = 0; + loop { + let byte = self.read_u8(); + result |= u64::from(byte & 0x7F) << shift; + shift += 7; + if byte & 0x80 == 0 { + // Sign-extend. + if shift < 64 && byte & 0x40 != 0 { + result |= u64::MAX << shift; + } + return result as i64; + } + } + } +} + +/// Offset with a value-format-only encoding (application part must be zero — +/// LLVM uses these for the call-site table). +unsafe fn read_encoded_offset(reader: &mut DwarfReader, encoding: u8) -> Result { + if encoding == DW_EH_PE_omit || encoding & 0xF0 != 0 { + return Err(()); + } + Ok(match encoding & 0x0F { + // LLVM uses absptr for offsets as well as pointers. + DW_EH_PE_absptr => reader.read_unaligned::(), + DW_EH_PE_uleb128 => reader.read_uleb128() as usize, + DW_EH_PE_udata2 => reader.read_unaligned::() as usize, + DW_EH_PE_udata4 => reader.read_unaligned::() as usize, + DW_EH_PE_udata8 => reader.read_unaligned::() as usize, + DW_EH_PE_sleb128 => reader.read_sleb128() as usize, + DW_EH_PE_sdata2 => reader.read_unaligned::() as usize, + DW_EH_PE_sdata4 => reader.read_unaligned::() as usize, + DW_EH_PE_sdata8 => reader.read_unaligned::() as usize, + _ => return Err(()), + }) +} + +/// Pointer with an application part. Perry LSDAs use absptr or pcrel (the +/// encodings LLVM emits for the landing-pad base on Mach-O and ELF); +/// textrel/datarel/funcrel/aligned never appear and are rejected. +unsafe fn read_encoded_pointer( + reader: &mut DwarfReader, + encoding: u8, + _func_start: usize, +) -> Result { + if encoding == DW_EH_PE_omit { + return Err(()); + } + let base: usize = match encoding & 0x70 { + DW_EH_PE_absptr => 0, + // Relative to the address of the encoded value itself. + DW_EH_PE_pcrel => reader.ptr as usize, + _ => return Err(()), + }; + let mut ptr = if base == 0 { + if encoding & 0x0F != DW_EH_PE_absptr { + return Err(()); + } + reader.read_unaligned::() + } else { + base.wrapping_add(read_encoded_offset(reader, encoding & 0x0F)?) + }; + if encoding & DW_EH_PE_indirect != 0 { + ptr = *(ptr as *const usize); + } + Ok(ptr) +} + +// Keep the personality (and therefore this module's symbols) out of +// dead-strip's reach in the static archives: generated code references +// `perry_eh_personality` by name only. +#[used] +static _KEEP_PERSONALITY: unsafe extern "C" fn( + c_int, + UnwindAction, + u64, + *mut UnwindException, + *mut UnwindContext, +) -> UnwindReasonCode = perry_eh_personality; + +#[cfg(all(test, not(target_os = "windows")))] +mod tests { + use super::*; + + /// Build a synthetic LSDA (uleb128 call-site encoding, DW_EH_PE_omit + /// bases — the shape LLVM emits for small functions) and check the walk. + fn synth_lsda(call_sites: &[(u64, u64, u64, u64)]) -> Vec { + fn uleb(out: &mut Vec, mut v: u64) { + loop { + let mut b = (v & 0x7F) as u8; + v >>= 7; + if v != 0 { + b |= 0x80; + } + out.push(b); + if v == 0 { + break; + } + } + } + let mut body = Vec::new(); + for &(start, len, lpad, action) in call_sites { + uleb(&mut body, start); + uleb(&mut body, len); + uleb(&mut body, lpad); + uleb(&mut body, action); + } + let mut lsda = vec![ + DW_EH_PE_omit, // lpstart: omitted → func_start + DW_EH_PE_omit, // ttype: omitted + DW_EH_PE_uleb128, // call-site encoding + ]; + uleb(&mut lsda, body.len() as u64); + lsda.extend_from_slice(&body); + lsda + } + + #[test] + fn walk_finds_covering_call_site() { + let lsda = synth_lsda(&[(0x10, 0x8, 0x40, 1), (0x20, 0x10, 0x80, 1)]); + let base = 0x1000usize; + let f = |ip: usize| unsafe { find_landing_pad_in_lsda(lsda.as_ptr(), base + ip, base) }; + assert_eq!(f(0x14).unwrap(), Some(base + 0x40)); + assert_eq!(f(0x2F).unwrap(), Some(base + 0x80)); + // Outside every range: plain call site, no handler here. + assert_eq!(f(0x0F).unwrap(), None); + assert_eq!(f(0x19).unwrap(), None); + assert_eq!(f(0x31).unwrap(), None); + } + + #[test] + fn zero_lpad_means_no_handler() { + let lsda = synth_lsda(&[(0x10, 0x8, 0, 0)]); + let base = 0x2000usize; + let got = unsafe { find_landing_pad_in_lsda(lsda.as_ptr(), base + 0x12, base) }; + assert_eq!(got.unwrap(), None); + } + + #[test] + fn empty_call_site_table_is_no_handler() { + let lsda = synth_lsda(&[]); + let got = unsafe { find_landing_pad_in_lsda(lsda.as_ptr(), 0x3000, 0x3000) }; + assert_eq!(got.unwrap(), None); + } + + #[test] + fn leb128_readers() { + let bytes = [0x7Fu8]; // sleb -1 + let mut r = DwarfReader::new(bytes.as_ptr()); + assert_eq!(unsafe { r.read_sleb128() }, -1); + let bytes2 = [0xC0u8, 0x00]; // 0x40 with continuation, then 0 → 64 + let mut r2 = DwarfReader::new(bytes2.as_ptr()); + assert_eq!(unsafe { r2.read_sleb128() }, 64); + let bytes3 = [0xE5u8, 0x8E, 0x26]; // uleb 624485 (DWARF spec example) + let mut r3 = DwarfReader::new(bytes3.as_ptr()); + assert_eq!(unsafe { r3.read_uleb128() }, 624485); + } +} diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index af6072e1fb..8ccd1ca60c 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -63,8 +63,30 @@ const MAX_TRY_DEPTH: usize = 1024; // TLS; the arrays live on the heap. `[T]` indexing on `Box<[T]>` is // unchanged, so the accessors below need no edits. (Mirrors the // TRANSITION_CACHE / VTABLE_IC / INTERN_TABLE boxing.) +/// How a handler-stack entry catches (#7302). +/// +/// `Setjmp`: the handler frame armed a `jmp_buf` (generated setjmp-based +/// `try` while the old lowering exists, plus the Rust-side +/// `js_call_catching` boundary trap, which keeps setjmp forever — Rust +/// cannot catch a foreign unwind). `js_throw` reaches these via `longjmp`. +/// +/// `Unwind`: an invoke/landingpad `try` in generated code (pushed by +/// `js_eh_try_push`). `js_throw` reaches these via +/// `_Unwind_RaiseException`; the unwinder finds the landing pad of the +/// innermost `try`-containing generated frame, which is exactly this entry +/// (handler-stack order mirrors stack order, and an entry above it would +/// have been popped or would itself be the throw target). +#[derive(Copy, Clone, PartialEq, Eq)] +enum HandlerKind { + Setjmp, + Unwind, +} + struct ExceptionState { jump_buffers: Box<[JmpBuf]>, + /// Catch mechanism per open handler, in lockstep with `jump_buffers` + /// (whose slot is simply unused for `Unwind` entries). + handler_kinds: Box<[HandlerKind]>, /// Shadow-stack depth captured when each `try` block was pushed, so the /// unwind path can drop the orphaned frames `longjmp` leaves behind (see /// `js_throw` / issue #1830). Indexed by try-depth, in lockstep with @@ -105,6 +127,7 @@ impl ExceptionState { fn new() -> Self { ExceptionState { jump_buffers: vec![JmpBuf::new(); MAX_TRY_DEPTH].into_boxed_slice(), + handler_kinds: vec![HandlerKind::Setjmp; MAX_TRY_DEPTH].into_boxed_slice(), shadow_savepoints: vec![ShadowSavepoint::EMPTY; MAX_TRY_DEPTH].into_boxed_slice(), runtime_handle_savepoints: vec![0usize; MAX_TRY_DEPTH].into_boxed_slice(), call_method_depths: vec![0u32; MAX_TRY_DEPTH].into_boxed_slice(), @@ -133,11 +156,24 @@ fn with_exception_state(f: impl FnOnce(*mut ExceptionState) -> R) -> R { /// The generated code must call setjmp() directly with this pointer. #[no_mangle] pub extern "C" fn js_try_push() -> *mut i32 { + try_push_with_kind(HandlerKind::Setjmp) +} + +/// Push a handler for an invoke/landingpad `try` (#7302). Same savepoint +/// recording as `js_try_push`, but no jmp_buf is armed — `js_throw` reaches +/// this handler via `_Unwind_RaiseException` and the frame's landing pad. +#[no_mangle] +pub extern "C" fn js_eh_try_push() { + try_push_with_kind(HandlerKind::Unwind); +} + +fn try_push_with_kind(kind: HandlerKind) -> *mut i32 { with_exception_state(|s| unsafe { if (*s).try_depth >= MAX_TRY_DEPTH { panic!("Try block nesting too deep"); } let depth = (*s).try_depth; + (*s).handler_kinds[depth] = kind; // Capture the shadow-stack depth now, before the protected region // can push any callee frames, so the unwind path can restore to // exactly this point and drop the frames `longjmp` orphans (#1830). @@ -206,11 +242,11 @@ pub fn js_call_catching(f: impl FnOnce() -> f64) -> Result { /// Throw an exception with the given value #[no_mangle] pub extern "C" fn js_throw(value: f64) -> ! { - // Pull the jmp_buf pointer out under the TLS borrow, then drop the - // borrow before calling longjmp (longjmp doesn't return, so leaving - // the TLS access "open" would leave the cell permanently borrowed - // on this thread; in practice UnsafeCell tolerates it but the - // shorter scope keeps things tidy). + // Pull the transport decision out under the TLS borrow, then act after + // dropping it (neither longjmp nor a raise returns here, so leaving the + // TLS access "open" would leave the cell permanently borrowed on this + // thread; in practice UnsafeCell tolerates it but the shorter scope + // keeps things tidy). let jb_ptr: *mut i32 = with_exception_state(|s| unsafe { crate::gc::runtime_store_root_nanbox_f64_raw_slot(&raw mut (*s).current_exception, value); (*s).has_exception = true; @@ -265,9 +301,33 @@ pub extern "C" fn js_throw(value: f64) -> ! { // truncate/decrement epilogues). #[cfg(feature = "dyn-eval")] crate::dyn_eval::interp_restore((*s).dyn_eval_savepoints[depth]); - (*s).jump_buffers[depth].as_mut_ptr() + // The savepoint restores above are transport-independent: the unwind + // path skips Rust cleanups exactly like longjmp does (the runtime is + // built panic=abort; see crate::eh), so restoring at throw time is + // correct for both. + match (*s).handler_kinds[depth] { + HandlerKind::Setjmp => (*s).jump_buffers[depth].as_mut_ptr(), + HandlerKind::Unwind => std::ptr::null_mut(), + } }); - unsafe { longjmp(jb_ptr, 1) } + if !jb_ptr.is_null() { + unsafe { longjmp(jb_ptr, 1) } + } + // Invoke/landingpad handler: raise. The unwinder transfers control to + // the innermost try-containing generated frame's landing pad — the + // handler this entry describes. Returning here means the walk failed + // DESPITE an armed handler: lost unwind tables between the throw point + // and the handler frame (e.g. a runtime rebuilt without + // -C force-unwind-tables). That is a build/configuration defect, not a + // JS error — fail loudly instead of masking it as an uncaught throw. + let reason = crate::eh::raise_perry_exception(); + eprintln!( + "perry: FATAL: exception transport failed (reason={reason}): a try \ + handler is armed but the unwinder found no landing pad. The runtime \ + or an intermediate object was built without unwind tables." + ); + print_uncaught(value); + std::process::abort(); } /// Get the current exception value diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 004811204e..c74f4ae3b8 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -64,6 +64,7 @@ pub mod dgram_reactor; pub mod disposable; pub mod dns; pub mod dns_resolver; +pub mod eh; pub mod embedded; pub mod error; pub mod event_pump; diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 2194ce4fbb..53ec451680 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -823,6 +823,12 @@ fn compute_object_cache_key_with_env( "env_target_cpu", env_var("PERRY_TARGET_CPU").as_deref().unwrap_or(""), ); + // #7302: PERRY_EH=invoke flips try/catch lowering from setjmp/longjmp to + // invoke/landingpad — structurally different IR for every try-containing + // function. Serving a cached object from the other mode would silently + // mix exception transports within one binary. (Temporary flag; deleted + // with the setjmp path when the default flips.) + h.field("env_eh", env_var("PERRY_EH").as_deref().unwrap_or("")); // Codegen tuning/emission toggles (#6394). Each is read by perry-codegen // at compile time and changes the emitted IR / .o bytes, so a warm cache // must not serve an object built under a different setting. These are diff --git a/scripts/gc_root_dominance_check.py b/scripts/gc_root_dominance_check.py index 6ff59e6297..ac12d903c5 100755 --- a/scripts/gc_root_dominance_check.py +++ b/scripts/gc_root_dominance_check.py @@ -108,13 +108,22 @@ # strict form above declines it, that is a parser gap and must be loud. LABEL_SHAPED_RE = re.compile(r"^[^\s=]+:\s*(?:;.*)?$") ASSIGN_RE = re.compile(r"^\s*%([\w.$]+)\s*=\s*(.*)$") -CALL_RE = re.compile(r"\bcall\s+[^@]*@([\w.$]+)\(") +# `invoke` (#7302) is a call with an unwind edge — it must be seen as a call +# here or every collecting call inside a `try` body would be invisible to the +# dominance analysis (a silent false-green for exactly the functions where +# rooting is hardest). +CALL_RE = re.compile(r"\b(?:call|invoke)\s+[^@]*@([\w.$]+)\(") BIND_RE = re.compile(r"call void @js_shadow_slot_bind\(i32 (\d+), ptr %([\w.$]+)\)") CLEAR_RE = re.compile(r"call void @js_shadow_slot_set\(i32 (\d+), i64 0\)") STORE_RE = re.compile(r"^\s*store\s+([\w\[\]x* ]+?)\s+([^,]+),\s*ptr %([\w.$]+)") BR_UNCOND_RE = re.compile(r"^\s*br label %([\w.$]+)") BR_COND_RE = re.compile(r"^\s*br i1 [^,]+, label %([\w.$]+), label %([\w.$]+)") SWITCH_LABEL_RE = re.compile(r"label %([\w.$]+)") +# Invoke edges (#7302): normal destination + unwind destination. The invoke +# terminates its block; the continuation label follows immediately in the +# emitted text and both successors must appear in the CFG or the landing pad +# (and everything reached through it) would be dropped as unreachable. +INVOKE_EDGE_RE = re.compile(r"\binvoke\b.*\bto label %([\w.$]+) unwind label %([\w.$]+)") class Insn: @@ -219,6 +228,11 @@ def build_cfg(f): if t.strip().startswith("switch"): for lbl in SWITCH_LABEL_RE.findall(t): f.succs[b].add(lbl) + continue + m = INVOKE_EDGE_RE.search(t) + if m: + f.succs[b].add(m.group(1)) + f.succs[b].add(m.group(2)) for b, ss in list(f.succs.items()): for s in ss: f.preds[s].add(b) From 099f8b842a9da5edd12161490a4e5723337ceb6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 12:21:35 +0200 Subject: [PATCH 03/13] =?UTF-8?q?test(eh):=20#7302=20exception=20corpus=20?= =?UTF-8?q?=E2=80=94=20structural=20paths,=20cross-helper=20throws,=20GC?= =?UTF-8?q?=20throw-across-collection=20probe;=20eh-abort=20dev=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 12 +++ docs/invoke-eh-experiment.md | 39 ++++++++ ...est_gap_7302_gc_throw_across_collection.ts | 62 ++++++++++++ test-files/test_gap_7302_invoke_eh_paths.ts | 94 +++++++++++++++++++ ...est_gap_7302_throw_across_helper_frames.ts | 53 +++++++++++ 5 files changed, 260 insertions(+) create mode 100644 test-files/test_gap_7302_gc_throw_across_collection.ts create mode 100644 test-files/test_gap_7302_invoke_eh_paths.ts create mode 100644 test-files/test_gap_7302_throw_across_helper_frames.ts diff --git a/Cargo.toml b/Cargo.toml index eb4a69a547..606f710d8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -209,6 +209,18 @@ codegen-units = 16 # edit/build loop stays short. Intended local loop: # cargo check -p perry # fastest correctness feedback # cargo build --profile perry-dev -p perry # optimized local development +# Invoke-EH development profile (#7302): the runtime/stdlib static archives +# linked into compiled programs under PERRY_EH=invoke. panic=abort keeps Rust +# frames cleanup-free so a JS unwind crossing them has exactly longjmp +# semantics (and no RFC-2945 extern-"C" abort guards fire); it MUST be paired +# with RUSTFLAGS="-C force-unwind-tables=yes" or the unwinder cannot step +# those frames at all — js_throw fails loudly if that happens (see +# perry-runtime/src/eh.rs). Temporary alongside the PERRY_EH flag; the +# default profiles adopt this configuration when the setjmp path is deleted. +[profile.eh-abort] +inherits = "perry-dev" +panic = "abort" + [profile.perry-dev] inherits = "release" lto = false diff --git a/docs/invoke-eh-experiment.md b/docs/invoke-eh-experiment.md index 8e54a44402..6c78495497 100644 --- a/docs/invoke-eh-experiment.md +++ b/docs/invoke-eh-experiment.md @@ -155,6 +155,45 @@ codegen is shared; only the dispatch/landing shape is per-triple (exactly how mandatory for all functions, so the cross-Rust-frame story has no force-unwind-tables analogue there. +## Phase 1 — implementation (landed on the branch behind `PERRY_EH=invoke`) + +- **Runtime** (`perry-runtime/src/eh.rs` + `exception.rs`): `perry_eh_personality` + (ported Itanium LSDA walk, catch-all — Rust std's personality trimmed of + type-table/filter logic, MIT/Apache-2.0), per-thread `PERRYJS\0` exception + object, `js_eh_try_push()` (same savepoint recording as `js_try_push`, no + jmp_buf), and a `HandlerKind` per handler-stack entry: `Setjmp` entries + (old lowering + every Rust-side boundary trap — `js_call_catching`, + combinators, iterator/timer/promisify traps, all of which pair + `js_try_push` with `ffi::setjmp`) are reached by `longjmp`; `Unwind` + entries by `_Unwind_RaiseException`. A raise that returns despite an armed + handler aborts loudly naming lost unwind tables (the RUSTFLAGS foot-gun). +- **Codegen**: EH scope stack on the shared `RegCounter`; + `call`/`call_void`/`call_indirect` emit + `invoke … to label %eh.contN unwind label %lpad` plus a flush-left inline + continuation label whenever a scope is active and the callee can throw + (`llvm.*`, `js_shadow_*`/`js_gc_*`, the EH bookkeeping five, and + `#2/#3/#4`-audited helpers stay plain calls). `lower_try_invoke` mirrors + the setjmp CFG exactly — same catch-entry sequence, same catch-param + binding (#7209), same finally duplication — with `emit_eh_dispatch` + replacing the setjmp dispatch and scope push/pop replacing + `enter/exit_try_region`. Async rejection boundary converted the same way. + Invoke-mode functions get `personality ptr @perry_eh_personality` and no + `#0`/`#1` groups, no volatile pass, no noinline. +- **Return/break/continue inside `try`**: unchanged — finally inlining is a + HIR-level transform (`perry-transform/src/finally_inline.rs`) whose clones + sit inside the try body, and its documented limitation (a throwing clone + routes to the same try's handler) is transport-independent, so behavior is + bit-identical to the setjmp path. +- **Tooling**: `scripts/gc_root_dominance_check.py` learned `invoke` (CALL_RE + + CFG edges for both destinations) — otherwise every collecting call inside + a `try` would be invisible to the dominance analysis, a silent false-green. + `LlBlock::contains_gc_unsafe_call` (#5093) matches `invoke` too. + `PERRY_EH` participates in the object-cache key (#6394 rule). +- **Dev profile**: `[profile.eh-abort]` (inherits perry-dev, `panic=abort`) + + `RUSTFLAGS="-C force-unwind-tables=yes"` builds the runtime archives for + invoke-mode testing without flipping the workspace default during the flag + period. + ## Phase 1+ design notes (running) - Handler bookkeeping: `js_try_push` today returns a jmp_buf and the generated diff --git a/test-files/test_gap_7302_gc_throw_across_collection.ts b/test-files/test_gap_7302_gc_throw_across_collection.ts new file mode 100644 index 0000000000..93ccc3e7b5 --- /dev/null +++ b/test-files/test_gap_7302_gc_throw_across_collection.ts @@ -0,0 +1,62 @@ +// #7302 GC × exception-transport probe: allocate inside a `try`, throw +// ACROSS a collection point, and verify both the caught value and the +// locals that were live in the catching frame. No such probe existed, which +// is exactly why the statepoint experiment's unsound `has_try` fallback went +// unnoticed (#7174). +// +// Three pressure points: +// 1. The thrown object is allocated after heavy churn, so it is young when +// the unwind happens (a mover would relocate it; a sweep bug frees it). +// 2. The frames being unwound past hold live allocations of their own — +// their shadow frames are dropped by the savepoint restore, and the +// catching frame's locals must SURVIVE (they are roots of the catcher, +// not of the unwound callees). +// 3. The catch body churns again before reading anything, so a stale +// pointer cannot masquerade as correct. + +function churn(n: number): number { + const a: any[] = []; + for (let i = 0; i < n; i++) { + a.push({ i: i, s: "c" + (i & 7) }); + } + return a.length; +} + +function thrower(depth: number, tag: number): number { + // Live allocation in every frame the unwind will discard. + const mine = { tag: tag, depth: depth, pad: "x" + depth }; + if (depth === 0) { + churn(400); + const err: any = new Error("gc-throw-" + tag); + err.payload = { tag: tag, arr: [tag, tag + 1, tag + 2] }; + throw err; + } + const r = thrower(depth - 1, tag) + mine.depth; + return r; +} + +function run(): string { + let bad = 0; + for (let r = 0; r < 200; r++) { + // Locals of the CATCHING frame, allocated before the try — these must + // survive the collection triggered on the throw path. + const keeper = { r: r, name: "keeper" + r, list: [r, r * 2, r * 3] }; + const keeperStr = "s" + r; + try { + churn(300); + thrower(40, r); + bad += 1000; // unreachable + } catch (e: any) { + churn(400); + if (e.message !== "gc-throw-" + r) bad++; + if (e.payload.tag !== r) bad++; + if (e.payload.arr[2] !== r + 2) bad++; + if (keeper.name !== "keeper" + r) bad++; + if (keeper.list[1] !== r * 2) bad++; + if (keeperStr !== "s" + r) bad++; + } + } + return bad === 0 ? "ok" : "BAD:" + bad; +} + +console.log(run()); diff --git a/test-files/test_gap_7302_invoke_eh_paths.ts b/test-files/test_gap_7302_invoke_eh_paths.ts new file mode 100644 index 0000000000..3c4c6b1935 --- /dev/null +++ b/test-files/test_gap_7302_invoke_eh_paths.ts @@ -0,0 +1,94 @@ +// Every structural try path: caught, finally-on-both-edges, nested rethrow, +// return-inside-try, catch-with-finally fail path, loop counter mutation. +function basic(): string { + try { + throw new Error("boom"); + } catch (e) { + return "caught:" + (e as Error).message; + } +} +console.log(basic()); + +function finBoth(x: boolean): string { + let log = ""; + try { + log += "T"; + if (x) throw new Error("x"); + log += "t"; + } catch { + log += "C"; + } finally { + log += "F"; + } + return log; +} +console.log(finBoth(true), finBoth(false)); + +function nestedRethrow(): string { + try { + try { + throw new Error("inner"); + } catch (e) { + throw new Error("outer:" + (e as Error).message); + } + } catch (e2) { + return (e2 as Error).message; + } +} +console.log(nestedRethrow()); + +function retInTry(): string { + let log = ""; + try { + log += "T"; + return log + "|ret"; + } finally { + log += "F"; + console.log("finally-ran:" + log); + } +} +console.log(retInTry()); + +function catchFinallyFail(): string { + try { + try { + throw new Error("a"); + } catch { + throw new Error("b"); + } finally { + console.log("cf-finally"); + } + } catch (e) { + return "outer-caught:" + (e as Error).message; + } +} +console.log(catchFinallyFail()); + +function volatileHazard(): number { + let acc = 0; + for (let i = 0; i < 100; i++) { + acc += i; + } + try { + acc = 4141; + acc += 800; + throw new Error("bump"); + } catch { + acc += 1; + } + return acc; +} +console.log(volatileHazard()); + +function tryFinallyRepropagates(): string { + try { + try { + throw new Error("keep-me"); + } finally { + console.log("tf-finally"); + } + } catch (e) { + return "repropagated:" + (e as Error).message; + } +} +console.log(tryFinallyRepropagates()); diff --git a/test-files/test_gap_7302_throw_across_helper_frames.ts b/test-files/test_gap_7302_throw_across_helper_frames.ts new file mode 100644 index 0000000000..1f5db01bee --- /dev/null +++ b/test-files/test_gap_7302_throw_across_helper_frames.ts @@ -0,0 +1,53 @@ +// Throws that cross runtime Rust helper frames (the Phase-2 question, on the +// real runtime): JSON.parse's parser, a throwing getter reached through the +// property-resolution helper, a throwing toString reached through +// string-coercion, and Array.prototype.map's callback trampoline. +try { + JSON.parse("{nope"); +} catch (e) { + console.log("json:", (e as Error).constructor.name); +} + +const obj = { + get boom(): number { + throw new Error("getter-threw"); + }, +}; +try { + console.log((obj as any).boom); +} catch (e) { + console.log("getter:", (e as Error).message); +} + +const weird = { + toString(): string { + throw new Error("tostring-threw"); + }, +}; +try { + console.log("x" + (weird as any)); +} catch (e) { + console.log("tostring:", (e as Error).message); +} + +try { + [1, 2, 3].map((v) => { + if (v === 2) throw new Error("map-cb"); + return v; + }); +} catch (e) { + console.log("map:", (e as Error).message); +} + +// Deep recursion inside try (shadow-stack savepoint restore across many +// unwound generated frames). +function deep(n: number): number { + if (n === 0) throw new Error("bottom"); + return deep(n - 1) + 1; +} +try { + deep(500); +} catch (e) { + console.log("deep:", (e as Error).message); +} +console.log("done"); From 1e1dd82b6dc115c0c150975e4299914453b88199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 12:29:59 +0200 Subject: [PATCH 04/13] fix(eh): rewrite phi predecessor labels past inline invoke splits (#7302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invoke split moves a block's CFG tail behind eh.contN labels; the ~150 ctx.block().label captures used for phi incoming edges would name the pre-split header. Render-time pass in to_ir maps header→tail (terminators always live in the last segment) and rewrites only phi pairs — branch targets keep naming headers. Unit-tested; smoke corpus (structural paths, cross-helper throws, async boundary, uncaught) matches Node byte-for-byte under PERRY_EH=invoke with the eh-abort runtime. --- crates/perry-codegen/src/eh_mode.rs | 107 +++++++++++++++++++++++++++ crates/perry-codegen/src/function.rs | 7 ++ 2 files changed, 114 insertions(+) diff --git a/crates/perry-codegen/src/eh_mode.rs b/crates/perry-codegen/src/eh_mode.rs index 8b38d7fa62..7a1171ab36 100644 --- a/crates/perry-codegen/src/eh_mode.rs +++ b/crates/perry-codegen/src/eh_mode.rs @@ -14,6 +14,7 @@ //! (`perry/src/commands/compile/object_cache.rs`) — the two modes emit //! structurally different IR for every function containing a `try`. +use std::collections::HashMap; use std::sync::OnceLock; pub(crate) fn invoke_eh_enabled() -> bool { @@ -35,6 +36,68 @@ pub(crate) fn invoke_eh_enabled() -> bool { /// `js_shadow_*` (GC shadow-stack bookkeeping: TLS pushes/pops/stores) and /// `js_gc_*` (collection entry points) cannot throw JS by construction — /// the GC has no throw path; allocation failure is a Rust abort. +/// Rewrite `phi` predecessor labels after inline invoke splits (#7302). +/// +/// An `invoke` emitted mid-block is followed by an inline `eh.contN:` label, +/// so the ORIGINAL block label no longer names the block that actually +/// branches onward — the last continuation segment does. Codegen captures +/// predecessor labels for phis via `ctx.block().label` at ~150 sites; rather +/// than auditing every capture, this render-time pass maps each original +/// block label to its tail segment and rewrites the phi incoming-edge labels +/// to match. Branch/switch/invoke TARGETS are untouched — they name block +/// headers, which splits never move. Sound because a block's terminator +/// always lives in its last segment (inline labels are emitted only directly +/// after an invoke, never after a terminator), so the tail segment is the +/// one true CFG predecessor for every edge the original block owned. +pub(crate) fn rewrite_phi_predecessors(ir: &str) -> String { + let mut tail: HashMap<&str, &str> = HashMap::new(); + let mut cur: Option<&str> = None; + for line in ir.lines() { + if let Some(lbl) = flush_left_label(line) { + if lbl.starts_with("eh.cont") { + if let Some(c) = cur { + tail.insert(c, lbl); + } + } else { + cur = Some(lbl); + } + } + } + tail.retain(|k, v| k != v); + if tail.is_empty() { + return ir.to_string(); + } + let mut out = String::with_capacity(ir.len() + 64); + for line in ir.lines() { + if line.contains(" = phi ") { + let mut s = line.to_string(); + for (orig, t) in &tail { + let from = format!(", %{} ]", orig); + if s.contains(&from) { + s = s.replace(&from, &format!(", %{} ]", t)); + } + } + out.push_str(&s); + } else { + out.push_str(line); + } + out.push('\n'); + } + out +} + +/// `name:` at column 0 with nothing after the colon — the shape of both +/// block headers and inline continuation labels in Perry's writer. +fn flush_left_label(line: &str) -> Option<&str> { + let rest = line.strip_suffix(':')?; + if rest.is_empty() { + return None; + } + rest.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '$' | '-')) + .then_some(rest) +} + pub(crate) fn callee_is_nothrow(name: &str) -> bool { name.starts_with("llvm.") || name.starts_with("js_shadow_") @@ -49,3 +112,47 @@ pub(crate) fn callee_is_nothrow(name: &str) -> bool { ) || !crate::module::helper_decl_attrs(name).is_empty() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phi_predecessors_follow_the_split_tail() { + let ir = "entry:\n\ + \x20 invoke void @f() to label %eh.cont1 unwind label %lpad\n\ + eh.cont1:\n\ + \x20 invoke void @g() to label %eh.cont2 unwind label %lpad\n\ + eh.cont2:\n\ + \x20 br label %merge\n\ + other:\n\ + \x20 br label %merge\n\ + merge:\n\ + \x20 %r9 = phi double [ %r1, %entry ], [ 1.0, %other ]\n\ + \x20 ret double %r9\n"; + let out = rewrite_phi_predecessors(ir); + assert!(out.contains("phi double [ %r1, %eh.cont2 ], [ 1.0, %other ]")); + // Branch targets keep naming the header. + assert!(out.contains("br label %merge")); + } + + #[test] + fn no_splits_is_identity() { + let ir = "entry:\n br label %m\nm:\n %p = phi double [ 1.0, %entry ]\n ret double %p\n"; + assert_eq!(rewrite_phi_predecessors(ir), ir); + } + + #[test] + fn unsplit_predecessor_pairs_are_untouched() { + let ir = "a:\n\ + \x20 invoke void @f() to label %eh.cont7 unwind label %l\n\ + eh.cont7:\n\ + \x20 br label %m\n\ + b:\n\ + \x20 br label %m\n\ + m:\n\ + \x20 %p = phi i64 [ %x, %a ], [ %y, %b ]\n"; + let out = rewrite_phi_predecessors(ir); + assert!(out.contains("[ %x, %eh.cont7 ], [ %y, %b ]")); + } +} diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index 8cfce6660d..980f36e319 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -757,6 +757,13 @@ impl LlFunction { } } + // Invoke-EH (#7302): inline invoke splits move a block's true CFG + // tail behind `eh.contN:` labels; phi incoming-edge labels captured + // at emit time must follow. Runs last so it sees the spliced text. + if self.needs_personality && ir.contains("eh.cont") { + return crate::eh_mode::rewrite_phi_predecessors(&ir); + } + ir } } From ba5d2bc519e342b9d16bafa65d4959c94a466dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 12:34:35 +0200 Subject: [PATCH 05/13] feat(eh): SEH funclet variant for windows-msvc (#7302) Same invoke-conversion infrastructure; per-triple dispatch shape (the setjmp_abi rule): catchswitch/catchpad/catchret with personality __C_specific_handler and a module-local filter accepting Perry's RaiseException code 0xE0504A53. Runtime twin eh_windows.rs raises via RaiseException; foreign SEH exceptions (AVs) keep unwinding past JS handlers, matching the setjmp path. Funclet IR shape verified against LLVM's verifier for x86_64-pc-windows-msvc at O0 and O2. --- crates/perry-codegen/src/block.rs | 8 ++ crates/perry-codegen/src/function.rs | 30 +++---- crates/perry-codegen/src/module.rs | 34 ++++++++ .../src/runtime_decls/strings_part2.rs | 13 ++- crates/perry-codegen/src/stmt/try_stmt.rs | 87 ++++++++++++++----- crates/perry-runtime/src/eh_windows.rs | 40 +++++++++ crates/perry-runtime/src/lib.rs | 4 + 7 files changed, 177 insertions(+), 39 deletions(-) create mode 100644 crates/perry-runtime/src/eh_windows.rs diff --git a/crates/perry-codegen/src/block.rs b/crates/perry-codegen/src/block.rs index 7e01aea944..bff991252b 100644 --- a/crates/perry-codegen/src/block.rs +++ b/crates/perry-codegen/src/block.rs @@ -195,6 +195,14 @@ impl LlBlock { self.terminated } + /// Mark the block terminated after emitting a terminator through + /// `emit_raw` (e.g. `catchswitch`/`catchret` in the SEH dispatch, which + /// have no dedicated builder methods). Without this the block would + /// silently accept further instructions after its terminator. + pub fn mark_terminated(&mut self) { + self.terminated = true; + } + /// #5093: true if this block contains a `call` to anything other than an /// `@llvm.*` intrinsic or an inline-asm marker. The class-field versioned /// loop uses this to verify AT COMPILE TIME that its fast clone came out diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index 980f36e319..247655031b 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -48,13 +48,14 @@ pub struct LlFunction { /// already implies the hint, so the two are never emitted together, and /// `has_try` (noinline) still wins over both in `to_ir`. pub inline_hint: bool, - /// Invoke-EH (#7302): this function contains `landingpad` instructions, - /// so its `define` line must carry - /// `personality ptr @perry_eh_personality`. Set by the invoke-mode - /// try/async-boundary lowering; orthogonal to `has_try` (which drives - /// the setjmp-era noinline/volatile machinery and stays false in invoke - /// mode). - pub needs_personality: bool, + /// Invoke-EH (#7302): this function contains landing pads (Itanium) or + /// funclet pads (SEH), so its `define` line must carry + /// `personality ptr @` — `perry_eh_personality` on Mach-O/ELF, + /// `__C_specific_handler` on windows-msvc. Set by the invoke-mode + /// try/async-boundary dispatch (which knows the target triple); + /// orthogonal to `has_try` (the setjmp-era noinline/volatile machinery, + /// which stays false in invoke mode). + pub personality: Option<&'static str>, blocks: Vec, block_counter: u32, reg_counter: Rc, @@ -221,7 +222,7 @@ impl LlFunction { has_try: false, force_inline: false, inline_hint: false, - needs_personality: false, + personality: None, blocks: Vec::new(), block_counter: 0, reg_counter: Rc::new(RegCounter::new()), @@ -631,13 +632,12 @@ impl LlFunction { } else { "" }; - // Invoke-EH (#7302): functions containing landing pads name their - // personality on the define line (LLVM: `define ... [fn attrs] + // Invoke-EH (#7302): functions containing landing/funclet pads name + // their personality on the define line (LLVM: `define ... [fn attrs] // [personality] { ... }`). - let personality = if self.needs_personality { - " personality ptr @perry_eh_personality" - } else { - "" + let personality = match self.personality { + Some(p) => format!(" personality ptr @{}", p), + None => String::new(), }; let mut ir = format!( "define {}{} @{}({}){}{} {{\n", @@ -760,7 +760,7 @@ impl LlFunction { // Invoke-EH (#7302): inline invoke splits move a block's true CFG // tail behind `eh.contN:` labels; phi incoming-edge labels captured // at emit time must follow. Runs last so it sees the spliced text. - if self.needs_personality && ir.contains("eh.cont") { + if self.personality.is_some() && ir.contains("eh.cont") { return crate::eh_mode::rewrite_phi_predecessors(&ir); } diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index ae54a4c1e6..4a9e8e1163 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -292,6 +292,40 @@ impl LlModule { )); } + /// Invoke-EH on windows-msvc (#7302): the SEH personality plus the + /// module-local `__except` filter every catchpad names. The filter + /// accepts exactly Perry's `RaiseException` code 0xE0504A53 ("PJS" | + /// 0xE0000000, `perry-runtime/src/eh.rs`), so foreign SEH exceptions + /// (access violations etc.) keep unwinding past JS handlers — the + /// setjmp path never caught those either. Rendered among the + /// declarations; LLVM accepts interleaved declares/defines. + pub fn declare_seh_machinery(&mut self) { + if self.declared_names.contains("__C_specific_handler") { + return; + } + self.declared_names + .insert("__C_specific_handler".to_string()); + self.declarations.push(( + "__C_specific_handler".to_string(), + "declare i32 @__C_specific_handler(...)".to_string(), + )); + self.declared_names.insert("perry_seh_filter".to_string()); + self.declarations.push(( + "perry_seh_filter".to_string(), + concat!( + "define internal i32 @perry_seh_filter(ptr %eptrs, ptr %frame) {\n", + "entry:\n", + " %rec = load ptr, ptr %eptrs\n", + " %code = load i32, ptr %rec\n", + " %ok = icmp eq i32 %code, -531609005\n", + " %r = zext i1 %ok to i32\n", + " ret i32 %r\n", + "}" + ) + .to_string(), + )); + } + /// [`Self::declare_function`] with LLVM *return* parameter attributes /// (`nonnull`, `noalias`, …), which sit before the return type and so /// cannot be expressed through the trailing attribute-group string. diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index e82e51e53e..dbc4096919 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -729,11 +729,16 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { // js_enter_finally() / js_leave_finally() bracket finally blocks. if crate::eh_mode::invoke_eh_enabled() { // Invoke-EH (#7302): handlers are armed by js_eh_try_push (savepoints - // only, no jmp_buf) and entered through landing pads; the personality - // is named on every try-containing define line. No setjmp is declared - // — which also keeps the `#0`/`#1` attribute groups out of the module. + // only, no jmp_buf) and entered through landing pads (Itanium) or + // catchpads (SEH on windows-msvc — same target-triple rule as the + // setjmp ABI selection below). No setjmp is declared — which also + // keeps the `#0`/`#1` attribute groups out of the module. module.declare_function("js_eh_try_push", VOID, &[]); - module.declare_personality(); + if module.target_triple.contains("-windows-") { + module.declare_seh_machinery(); + } else { + module.declare_personality(); + } } else { module.declare_function("js_try_push", PTR, &[]); // setjmp variant selection: decided by `crate::setjmp_abi` from the diff --git a/crates/perry-codegen/src/stmt/try_stmt.rs b/crates/perry-codegen/src/stmt/try_stmt.rs index 491f24b757..d3d9679c22 100644 --- a/crates/perry-codegen/src/stmt/try_stmt.rs +++ b/crates/perry-codegen/src/stmt/try_stmt.rs @@ -49,37 +49,84 @@ pub(super) fn emit_setjmp_dispatch(ctx: &mut FnCtx<'_>, exc_label: &str, normal_ /// Invoke-EH (#7302) counterpart of [`emit_setjmp_dispatch`]: arm the /// handler (`js_eh_try_push` — savepoints only, no jmp_buf), branch into -/// the protected body, and materialize the landing-pad block that funnels -/// the unwinder into `exc_label`. Returns the landing pad's label; the +/// the protected body, and materialize the unwind-target block(s) that +/// funnel the exception into `exc_label`. Returns the unwind label; the /// caller pushes it as the EH scope around the protected body so every /// potentially-throwing call inside carries the unwind edge. /// -/// The landing pad ignores the `{ ptr, i32 }` pair — the thrown value is -/// read back from the runtime's rooted TLS slot via `js_get_exception`, -/// exactly as the setjmp path does. Savepoint restores already ran at -/// throw time (`js_throw`), which is sound because the unwinder skips -/// Rust cleanups just like `longjmp` did (runtime built panic=abort; see -/// `perry-runtime/src/eh.rs`). +/// Two per-triple shapes (same rule as `crate::setjmp_abi`: decided by the +/// TARGET triple, not host `cfg!`): +/// +/// - Itanium (Mach-O/ELF): one landing-pad block — +/// `landingpad {ptr,i32} catch ptr null` → `br %exc_label`. The pair is +/// ignored; the thrown value is read back from the runtime's rooted TLS +/// slot via `js_get_exception`, exactly as the setjmp path did. +/// - SEH (windows-msvc): `catchswitch within none [pad] unwind to caller` → +/// `catchpad [ptr @perry_seh_filter]` → `catchret to %exc_label`. The +/// filter matches Perry's `RaiseException` code; foreign SEH exceptions +/// (access violations etc.) keep unwinding past JS handlers, matching the +/// setjmp path (which never caught them either). +/// +/// Savepoint restores already ran at throw time (`js_throw`), which is +/// sound because the unwinder skips Rust cleanups just like `longjmp` did +/// (runtime built panic=abort; see `perry-runtime/src/eh.rs`). pub(super) fn emit_eh_dispatch( ctx: &mut FnCtx<'_>, exc_label: &str, normal_label: &str, ) -> String { - ctx.func.needs_personality = true; - let lpad_idx = ctx.new_block("eh.lpad"); - let lpad_label = ctx.block_label(lpad_idx); + let msvc = ctx.target_triple.contains("-windows-"); + ctx.func.personality = Some(if msvc { + "__C_specific_handler" + } else { + "perry_eh_personality" + }); ctx.block().call_void("js_eh_try_push", &[]); - ctx.block().br(normal_label); - let saved = ctx.current_block; - ctx.current_block = lpad_idx; - let lp = ctx.block().next_reg(); - ctx.block() - .emit_raw(format!("{} = landingpad {{ ptr, i32 }} catch ptr null", lp)); - ctx.block().br(exc_label); - ctx.current_block = saved; - lpad_label + if msvc { + let cs_idx = ctx.new_block("eh.cs"); + let pad_idx = ctx.new_block("eh.pad"); + let cs_label = ctx.block_label(cs_idx); + let pad_label = ctx.block_label(pad_idx); + + ctx.block().br(normal_label); + + let saved = ctx.current_block; + ctx.current_block = cs_idx; + let cs = ctx.block().next_reg(); + ctx.block().emit_raw(format!( + "{} = catchswitch within none [label %{}] unwind to caller", + cs, pad_label + )); + ctx.block().mark_terminated(); + + ctx.current_block = pad_idx; + let pad = ctx.block().next_reg(); + ctx.block().emit_raw(format!( + "{} = catchpad within {} [ptr @perry_seh_filter]", + pad, cs + )); + ctx.block() + .emit_raw(format!("catchret from {} to label %{}", pad, exc_label)); + ctx.block().mark_terminated(); + ctx.current_block = saved; + cs_label + } else { + let lpad_idx = ctx.new_block("eh.lpad"); + let lpad_label = ctx.block_label(lpad_idx); + + ctx.block().br(normal_label); + + let saved = ctx.current_block; + ctx.current_block = lpad_idx; + let lp = ctx.block().next_reg(); + ctx.block() + .emit_raw(format!("{} = landingpad {{ ptr, i32 }} catch ptr null", lp)); + ctx.block().br(exc_label); + ctx.current_block = saved; + lpad_label + } } pub(crate) fn lower_try( diff --git a/crates/perry-runtime/src/eh_windows.rs b/crates/perry-runtime/src/eh_windows.rs new file mode 100644 index 0000000000..52397dcf2c --- /dev/null +++ b/crates/perry-runtime/src/eh_windows.rs @@ -0,0 +1,40 @@ +//! SEH exception transport for `try`/`catch` on Windows (#7302). +//! +//! The Itanium unwinder does not exist on windows-msvc; the invoke-EH +//! lowering emits funclet EH there instead (`catchswitch`/`catchpad` with +//! personality `__C_specific_handler` and a filter matching Perry's +//! exception code — see `perry-codegen`'s `declare_seh_machinery`). The +//! throw side is `RaiseException` with a Perry-owned code; the thrown JS +//! value stays in the GC-rooted TLS slot exactly as on every other target. +//! +//! MSVC x64 unwind tables (.pdata/.xdata) are mandatory for all functions, +//! so the cross-Rust-frame story needs no `force-unwind-tables` analogue: +//! the dispatcher steps runtime helper frames unconditionally, running no +//! Rust cleanups under panic=abort — the longjmp-equivalent semantics the +//! savepoint restores in `exception.rs` assume. + +/// `0xE0000000 | "PJS"` — customer-defined (bit 29 set), noncontinuable by +/// use. Must match the `-531609005` immediate in `perry_seh_filter` +/// (perry-codegen `declare_seh_machinery`). +pub const PERRY_SEH_CODE: u32 = 0xE050_4A53; + +const EXCEPTION_NONCONTINUABLE: u32 = 0x1; + +extern "system" { + fn RaiseException(code: u32, flags: u32, n_args: u32, args: *const usize); +} + +/// Raise the Perry SEH exception. Returns only if the exception came back +/// (no filter accepted it and something continued execution) — the caller +/// treats that as transport failure and aborts loudly. +pub(crate) fn raise_perry_exception() -> i32 { + unsafe { + RaiseException( + PERRY_SEH_CODE, + EXCEPTION_NONCONTINUABLE, + 0, + core::ptr::null(), + ); + } + -1 +} diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index c74f4ae3b8..96bd9744c6 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -64,6 +64,10 @@ pub mod dgram_reactor; pub mod disposable; pub mod dns; pub mod dns_resolver; +#[cfg(not(windows))] +pub mod eh; +#[cfg(windows)] +#[path = "eh_windows.rs"] pub mod eh; pub mod embedded; pub mod error; From 9e4bad8430a1a3cf1555893975a6dbcb766b1ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 12:39:43 +0200 Subject: [PATCH 06/13] feat(eh): unwind-table self-check on first js_eh_try_push (#7302) _Unwind_Backtrace across two #[inline(never)] runtime frames, once per process: a runtime built without -C force-unwind-tables (the RUSTFLAGS foot-gun) aborts loudly at the first try instead of stranding the first cross-helper throw. Windows exempt (MSVC x64 tables are mandatory). --- crates/perry-runtime/src/eh.rs | 57 +++++++++++++++++++++++++++ crates/perry-runtime/src/exception.rs | 6 +++ 2 files changed, 63 insertions(+) diff --git a/crates/perry-runtime/src/eh.rs b/crates/perry-runtime/src/eh.rs index acd50a7bcf..ddd070cc1b 100644 --- a/crates/perry-runtime/src/eh.rs +++ b/crates/perry-runtime/src/eh.rs @@ -64,6 +64,63 @@ extern "C" { fn _Unwind_GetRegionStart(ctx: *mut UnwindContext) -> usize; fn _Unwind_SetGR(ctx: *mut UnwindContext, reg_index: c_int, value: usize); fn _Unwind_SetIP(ctx: *mut UnwindContext, value: usize); + fn _Unwind_Backtrace( + trace: extern "C" fn(*mut UnwindContext, *mut core::ffi::c_void) -> UnwindReasonCode, + arg: *mut core::ffi::c_void, + ) -> UnwindReasonCode; +} + +// --------------------------------------------------------------------------- +// Unwind-table self-check. +// --------------------------------------------------------------------------- + +/// The exception transport requires the unwinder to step *through* runtime +/// Rust frames, which requires those frames to carry unwind tables. The +/// runtime is built `panic=abort` (no tables by default) plus +/// `-C force-unwind-tables=yes` — and that flag rides on RUSTFLAGS, which a +/// stray environment override silently drops. A runtime built that way +/// strands EVERY throw that crosses a helper frame. This check runs once, on +/// the first `js_eh_try_push` of the process: `_Unwind_Backtrace` uses the +/// same CFI the raise path does, so if it cannot see past this module's own +/// nested Rust frames, the raise path is broken too — abort loudly at the +/// first `try` instead of stranding the first cross-helper throw. +pub(crate) fn verify_unwind_tables_once() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let frames = selfcheck_frame_a(); + // With tables present the backtrace sees at least the two + // #[inline(never)] frames plus their callers; without them it stops + // after the first frame (or errors out with a count of 0/1). + if frames < 3 { + eprintln!( + "perry: FATAL: unwind tables are missing from this runtime \ + build ({frames} frame(s) visible to the unwinder). The \ + exception transport cannot cross runtime frames; rebuild \ + with RUSTFLAGS=\"-C force-unwind-tables=yes\" (see \ + docs/invoke-eh-experiment.md)." + ); + std::process::abort(); + } + }); +} + +#[inline(never)] +fn selfcheck_frame_a() -> usize { + std::hint::black_box(selfcheck_frame_b()) + usize::from(std::hint::black_box(false)) +} + +#[inline(never)] +fn selfcheck_frame_b() -> usize { + extern "C" fn count(_ctx: *mut UnwindContext, arg: *mut core::ffi::c_void) -> UnwindReasonCode { + unsafe { *(arg as *mut usize) += 1 }; + _URC_CONTINUE_UNWIND + } + let mut n: usize = 0; + unsafe { + _Unwind_Backtrace(count, &mut n as *mut usize as *mut core::ffi::c_void); + } + std::hint::black_box(n) } // DWARF register numbers for the exception-pointer / exception-selector diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index 8ccd1ca60c..40582c0bcc 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -164,6 +164,12 @@ pub extern "C" fn js_try_push() -> *mut i32 { /// this handler via `_Unwind_RaiseException` and the frame's landing pad. #[no_mangle] pub extern "C" fn js_eh_try_push() { + // First `try` of the process: prove the unwinder can step runtime + // frames (a runtime built without forced unwind tables would strand + // every cross-helper throw). Windows needs no check — MSVC x64 unwind + // tables are mandatory for all functions. + #[cfg(not(windows))] + crate::eh::verify_unwind_tables_once(); try_push_with_kind(HandlerKind::Unwind); } From bdeb1df568a596da2d8aed4125f36cf44943fe15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 12:40:26 +0200 Subject: [PATCH 07/13] style: cargo fmt --- crates/perry-codegen/src/module.rs | 3 ++- crates/perry-codegen/src/stmt/try_stmt.rs | 6 +----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index 4a9e8e1163..0395f21983 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -285,7 +285,8 @@ impl LlModule { if self.declared_names.contains("perry_eh_personality") { return; } - self.declared_names.insert("perry_eh_personality".to_string()); + self.declared_names + .insert("perry_eh_personality".to_string()); self.declarations.push(( "perry_eh_personality".to_string(), "declare i32 @perry_eh_personality(...)".to_string(), diff --git a/crates/perry-codegen/src/stmt/try_stmt.rs b/crates/perry-codegen/src/stmt/try_stmt.rs index d3d9679c22..6608296139 100644 --- a/crates/perry-codegen/src/stmt/try_stmt.rs +++ b/crates/perry-codegen/src/stmt/try_stmt.rs @@ -70,11 +70,7 @@ pub(super) fn emit_setjmp_dispatch(ctx: &mut FnCtx<'_>, exc_label: &str, normal_ /// Savepoint restores already ran at throw time (`js_throw`), which is /// sound because the unwinder skips Rust cleanups just like `longjmp` did /// (runtime built panic=abort; see `perry-runtime/src/eh.rs`). -pub(super) fn emit_eh_dispatch( - ctx: &mut FnCtx<'_>, - exc_label: &str, - normal_label: &str, -) -> String { +pub(super) fn emit_eh_dispatch(ctx: &mut FnCtx<'_>, exc_label: &str, normal_label: &str) -> String { let msvc = ctx.target_triple.contains("-windows-"); ctx.func.personality = Some(if msvc { "__C_specific_handler" From 78191e9c4f6d72cf8e5d0aa4f9a9f93ea4433a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 13:00:01 +0200 Subject: [PATCH 08/13] fix(eh): _Unwind_Backtrace trace fn must return _URC_NO_REASON (#7302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-check callback returned _URC_CONTINUE_UNWIND, which stops _Unwind_Backtrace after one frame — a false-positive FATAL on every invoke-mode binary. With the fix the check passes on real tables and the http-family crash set drops to the two pre-existing entries. --- crates/perry-runtime/src/eh.rs | 4 +++- docs/invoke-eh-experiment.md | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/eh.rs b/crates/perry-runtime/src/eh.rs index ddd070cc1b..ef4fdca5e1 100644 --- a/crates/perry-runtime/src/eh.rs +++ b/crates/perry-runtime/src/eh.rs @@ -114,7 +114,9 @@ fn selfcheck_frame_a() -> usize { fn selfcheck_frame_b() -> usize { extern "C" fn count(_ctx: *mut UnwindContext, arg: *mut core::ffi::c_void) -> UnwindReasonCode { unsafe { *(arg as *mut usize) += 1 }; - _URC_CONTINUE_UNWIND + // _URC_NO_REASON: the ONLY value that lets _Unwind_Backtrace keep + // walking — any other reason code stops the trace after one frame. + 0 } let mut n: usize = 0; unsafe { diff --git a/docs/invoke-eh-experiment.md b/docs/invoke-eh-experiment.md index 6c78495497..83c61b0365 100644 --- a/docs/invoke-eh-experiment.md +++ b/docs/invoke-eh-experiment.md @@ -194,6 +194,47 @@ force-unwind-tables analogue there. invoke-mode testing without flipping the workspace default during the flag period. +## Acceptance evidence (running) + +**Gap suite under `PERRY_EH=invoke`** (2026-08-03, macOS arm64, perry-dev +compiler + eh-abort runtime archives, `PERRY_SKIP_BUILD=1` harness run): +93.9% parity, 20 output mismatches, 9 crashes. Attribution: + +- **All 20 output mismatches are mode-independent** — byte-identical output + and exit codes when recompiled with the same binary/runtime under setjmp + mode. 8 are in `known_failures.json`; the other 12 are oracle-environment + (node cannot resolve npm fixtures — `package_json_reader:301`; enum tests + where `--experimental-strip-types` node errors — `run_main:107`) or + pre-existing at this main commit under the perry-dev profile (verified for + `test_gap_6301_event_target_subclass` and `test_gap_4510_enum_forward_ref` + against the unwind runtime + setjmp transport, i.e. main's exact + configuration). **Zero invoke-attributable output regressions.** +- The **9 crashes are all in the http/net/fetch family** and are a build- + coherence artifact of the ad-hoc environment, not a lowering bug: those + tests link prebuilt `perry-ext-*` archives whose *bundled* runtime was + compiled `panic=unwind` from older source. A JS unwind crossing an + ext-archive Rust frame hits the RFC-2945 abort guard — the crash output + says `panic in a function that cannot unwind` verbatim (probe scenario s4). + One of the three probed also segfaults identically under setjmp mode + (`fetch_instanceof_5433`, KNOWN), and `net_connect_bound_value` dies with + the same tokio no-reactor panic under both modes. Fix: coherent archives + (rebuilding the http-family ext crates under the eh-abort profile) — which + the final flip provides globally by putting `panic=abort` on the release + profile itself. +- The three new #7302 tests pass under invoke mode, **including the GC + throw-across-collection probe** (200 iterations of allocate-in-try → + throw across churn → verify caught value, error payload, and the catching + frame's locals). +- Subject-live check: the traced module IR for the structural-path test + contains 106 `invoke`/`landingpad`/`personality` lines and zero setjmp / + `returns_twice` / volatile machinery. + +**Smoke corpus** (structural paths incl. the #6385 volatile-hazard shape, +cross-helper throws incl. throwing getter/toString/JSON.parse/map-callback + +500-frame deep unwind, async boundary + generators + Promise.all combinator +interplay): byte-for-byte vs Node 26.5.1 under invoke mode; uncaught-throw +output byte-identical to the setjmp build. + ## Phase 1+ design notes (running) - Handler bookkeeping: `js_try_push` today returns a jmp_buf and the generated From 62a181de0aa7c5fcbd86228bcab5680c19370dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 13:05:41 +0200 Subject: [PATCH 09/13] docs(eh): gap-suite attribution + perf matrix with V8 calibration (#7302) --- docs/invoke-eh-experiment.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/invoke-eh-experiment.md b/docs/invoke-eh-experiment.md index 83c61b0365..e6235d62da 100644 --- a/docs/invoke-eh-experiment.md +++ b/docs/invoke-eh-experiment.md @@ -235,6 +235,42 @@ cross-helper throws incl. throwing getter/toString/JSON.parse/map-callback + interplay): byte-for-byte vs Node 26.5.1 under invoke mode; uncaught-throw output byte-identical to the setjmp build. +### Performance (macOS arm64, perry-dev compiler, 3-run medians) + +Both directions, as promised. Reference: the pinned Node oracle on the same +files (V8's own EH machinery). + +| microbenchmark | setjmp | invoke | node/V8 | +|---|---|---|---| +| b1: hot `try` that never throws (200×1M iters) | 5.6 s | **5.0 s** | 0.26 s* | +| b3: small try-containing fn in a hot loop (80M calls) | 2.31 s | **1.95 s** | 0.07 s* | +| b2: throw+catch every iteration (300k shallow throws) | **62 ms** | 451 ms | 110 ms | +| b4: 20k throws × 200-frame unwind | **19 ms** | 4.10 s | 168 ms | + +\* b1/b3's node column reflects V8's integer-loop JIT advantage (the known +AOT-vs-V8 integer-math gap), not EH — the EH-relevant comparison there is +perry-vs-perry. + +Reading: the non-throwing path — the case zero-cost EH exists for, and the +overwhelmingly common one — gets 10–20% faster (no `_setjmp` per entry, no +volatile-pinned locals, inlining unlocked). The throw path pays the +industry-standard price of real unwinding: ~1.5 µs per shallow throw +(2-phase walk; C++/Swift/Rust are in the same band; V8 pays ~0.37 µs) and +~1 µs per frame stepped on deep unwinds, vs `longjmp`'s O(1) register +restore. The b4 shape (exception-as-control-flow across 200 frames in a hot +loop) is the pathological case at ~24× V8; no gap/parity test moved +measurably. Optimization avenues if a real workload ever hits this: +per-frame step cost is macOS-libunwind-specific (Linux `.eh_frame_hdr` +stepping is cheaper — measure in CI), and generated frames without handlers +carry no personality, so the walk is pure CFI decode. + +A register-snapshot "fast transport" (save callee-saveds at try entry, jump +straight to the pad) was considered and rejected: it is `setjmp` by another +name — LLVM's EH model expects each unwound frame's callee-saved registers +restored by the unwinder, so a snapshot restore would resurrect try-entry +values for locals defined after the push, recreating exactly the volatile +problem this migration deletes. + ## Phase 1+ design notes (running) - Handler bookkeeping: `js_try_push` today returns a jmp_buf and the generated From 55fbf7e97ab0de70cfda9e34785672315b95e80d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 13:16:19 +0200 Subject: [PATCH 10/13] feat(eh)!: flip to invoke/landingpad, delete the setjmp lowering (#7302) The temporary PERRY_EH flag is gone: invoke/landingpad (SEH funclets on windows-msvc) is Perry's only exception lowering. Deleted wholesale: volatile_setjmp.rs (376 lines), setjmp_abi.rs (193), the #0 returns_twice / #1 noinline attribute groups, the try-region store tracking on RegCounter, the has_try noinline precedence, and the setjmp/js_try_push declarations. Try-containing functions now inline and optimize like any other. Build contract: [profile.release] panic=abort (longjmp-equivalent unwind semantics through runtime frames; no RFC-2945 abort guards) + force-unwind-tables via .cargo/config.toml, backstopped by the first-try _Unwind_Backtrace self-check. --- .cargo/config.toml | 10 + Cargo.toml | 25 +- crates/perry-codegen/src/block.rs | 55 +-- crates/perry-codegen/src/codegen/function.rs | 6 +- .../src/codegen/module_globals_emit.rs | 2 +- crates/perry-codegen/src/eh_mode.rs | 49 +-- crates/perry-codegen/src/expr/fs_await.rs | 2 +- crates/perry-codegen/src/expr/mod.rs | 2 +- crates/perry-codegen/src/function.rs | 68 +--- crates/perry-codegen/src/lib.rs | 2 - crates/perry-codegen/src/module.rs | 48 +-- .../src/runtime_decls/strings.rs | 2 +- .../src/runtime_decls/strings_part2.rs | 35 +- crates/perry-codegen/src/setjmp_abi.rs | 193 --------- crates/perry-codegen/src/stmt/mod.rs | 79 +--- crates/perry-codegen/src/stmt/try_stmt.rs | 331 ++++----------- crates/perry-codegen/src/volatile_setjmp.rs | 376 ------------------ crates/perry-runtime/src/exception.rs | 19 +- .../src/commands/compile/object_cache.rs | 6 - 19 files changed, 160 insertions(+), 1150 deletions(-) delete mode 100644 crates/perry-codegen/src/setjmp_abi.rs delete mode 100644 crates/perry-codegen/src/volatile_setjmp.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index a99cd51cbd..7cef5a32e2 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -11,3 +11,13 @@ min-publish-age = true [registry] global-min-publish-age = "7 days" + +# Invoke-EH (#7302): the exception transport steps runtime Rust frames via +# the system unwinder, and panic=abort (profile.release) omits unwind tables +# by default — without this flag every throw crossing a helper frame is +# stranded. NOTE: a user-set RUSTFLAGS environment variable OVERRIDES this +# file entirely (cargo uses exactly one rustflags source) — carry the flag +# in your RUSTFLAGS too. The runtime self-checks on the first `try` and +# aborts loudly if the tables are missing (perry-runtime/src/eh.rs). +[build] +rustflags = ["-C", "force-unwind-tables=yes"] diff --git a/Cargo.toml b/Cargo.toml index 606f710d8e..4db895fd8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,7 +92,18 @@ default-members = [ [profile.release] lto = "thin" # Thin LTO for faster builds and fewer duplicate symbols codegen-units = 1 # Better optimization, slower compile -panic = "unwind" # Allow catch_unwind for graceful error recovery +# panic=abort (#7302): the invoke/landingpad exception transport requires the +# unwinder to step THROUGH runtime Rust frames with longjmp-equivalent +# semantics — no cleanups, and no RFC-2945 abort-on-unwind guards (which +# panic=unwind plants in every extern "C" helper and which a JS throw +# crossing a helper frame would trip). Must be paired with +# `-C force-unwind-tables=yes` (.cargo/config.toml) or the unwinder cannot +# step the frames at all — js_eh_try_push self-checks this at runtime and +# fails loudly. Cost: catch_unwind-based panic recovery (spawn workers, +# worker_threads, UI callbacks) no longer catches — a runtime panic aborts +# the process. On the main JS→helper path panics already aborted (the +# RFC-2945 guards); cargo test is unaffected (test builds force unwind). +panic = "abort" strip = true # Strip symbols automatically opt-level = 3 # Maximum optimization @@ -209,18 +220,6 @@ codegen-units = 16 # edit/build loop stays short. Intended local loop: # cargo check -p perry # fastest correctness feedback # cargo build --profile perry-dev -p perry # optimized local development -# Invoke-EH development profile (#7302): the runtime/stdlib static archives -# linked into compiled programs under PERRY_EH=invoke. panic=abort keeps Rust -# frames cleanup-free so a JS unwind crossing them has exactly longjmp -# semantics (and no RFC-2945 extern-"C" abort guards fire); it MUST be paired -# with RUSTFLAGS="-C force-unwind-tables=yes" or the unwinder cannot step -# those frames at all — js_throw fails loudly if that happens (see -# perry-runtime/src/eh.rs). Temporary alongside the PERRY_EH flag; the -# default profiles adopt this configuration when the setjmp path is deleted. -[profile.eh-abort] -inherits = "perry-dev" -panic = "abort" - [profile.perry-dev] inherits = "release" lto = false diff --git a/crates/perry-codegen/src/block.rs b/crates/perry-codegen/src/block.rs index bff991252b..f44b93d4c4 100644 --- a/crates/perry-codegen/src/block.rs +++ b/crates/perry-codegen/src/block.rs @@ -9,8 +9,8 @@ //! sorts out the registers. Explicit `phi` nodes are still emitted for //! control-flow merges (if/else value context, short-circuit logical ops). -use std::cell::{Cell, Ref, RefCell}; -use std::collections::{HashMap, HashSet}; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; use std::rc::Rc; use crate::codegen::FpContractMode; @@ -62,21 +62,7 @@ impl Default for FpFlags { #[derive(Default)] pub struct RegCounter { value: Cell, - /// Nesting depth of the setjmp-protected regions codegen is currently - /// inside (a `try` body, or a `catch` body that a `finally` re-protects). - /// Tracked by *emission* depth rather than by block index so it covers - /// nested blocks, loops, nested `try`s and duplicated `finally` bodies - /// automatically — whatever is lowered while the region is open belongs - /// to it, no matter which basic block the instruction lands in. - try_region_depth: Cell, - /// Destination pointer of every `store` emitted while - /// `try_region_depth > 0`. These are the memory locations this function - /// can modify between a `setjmp` and its `longjmp`; the allocas among - /// them get `volatile` accesses at IR-render time so LLVM cannot promote - /// them into registers that `longjmp` would revert. See - /// `crate::volatile_setjmp` for the full argument (#6385). - try_region_stores: RefCell>, - /// Invoke-EH mode (#7302): stack of landing-pad labels for the active + /// Invoke-EH (#7302): stack of landing-pad labels for the active /// handler scopes, innermost last. While non-empty, every emitted call /// that can reach `js_throw` becomes an `invoke` unwinding to the top /// label (followed by an inline continuation label, so the emitting @@ -91,8 +77,6 @@ impl RegCounter { pub fn new() -> Self { Self { value: Cell::new(0), - try_region_depth: Cell::new(0), - try_region_stores: RefCell::new(HashSet::new()), eh_unwind_labels: RefCell::new(Vec::new()), } } @@ -117,36 +101,6 @@ impl RegCounter { self.value.set(v); v } - - /// Open a setjmp-protected region: every `store` emitted from here until - /// the matching `exit_try_region` is recorded as "modified between the - /// setjmp and a possible longjmp". - pub fn enter_try_region(&self) { - self.try_region_depth.set(self.try_region_depth.get() + 1); - } - - pub fn exit_try_region(&self) { - self.try_region_depth - .set(self.try_region_depth.get().saturating_sub(1)); - } - - pub fn try_region_stores(&self) -> Ref<'_, HashSet> { - self.try_region_stores.borrow() - } - - /// Record `line`'s store destination if we are inside a try region. - /// Called from [`LlBlock::emit`], the single choke point every emitter - /// (including `emit_raw`) funnels through. - fn note_emitted(&self, line: &str) { - if self.try_region_depth.get() == 0 { - return; - } - if let Some(ptr) = crate::volatile_setjmp::store_dest_ptr(line) { - if ptr.starts_with('%') { - self.try_region_stores.borrow_mut().insert(ptr.to_string()); - } - } - } } pub struct LlBlock { @@ -243,9 +197,6 @@ impl LlBlock { return; } let line = line.into(); - // #6385: note stores emitted inside a setjmp-protected region so - // `LlFunction::to_ir` can make the backing allocas volatile. - self.counter.note_emitted(&line); self.instructions.push(format!(" {}", line)); } diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index b755bf5b10..fcbebe7757 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -423,9 +423,9 @@ pub(super) fn compile_function( // callee; its `-O3` growth budget still refuses cold/oversized inlines, so // cold utility functions never bloat the binary (that is the anti-bloat // property, proved by the binary-size gate). We skip `alwaysinline` - // functions (the hint would be redundant), async/generator forms, and rely - // on `to_ir`'s `has_try`-first attribute precedence to keep any function - // whose body later turns out to need `noinline` (try/setjmp/volatile) out. + // functions (the hint would be redundant) and async/generator forms. + // Try-containing functions are ordinary inline candidates since #7302 + // (invoke-EH removed the setjmp-era noinline requirement). if !lf.force_inline && inline_hot_small_enabled() && (INLINE_HOT_SMALL_MIN..=inline_hot_small_size_cap()).contains(&f.body.len()) diff --git a/crates/perry-codegen/src/codegen/module_globals_emit.rs b/crates/perry-codegen/src/codegen/module_globals_emit.rs index 97c9c36f5b..c102953bc9 100644 --- a/crates/perry-codegen/src/codegen/module_globals_emit.rs +++ b/crates/perry-codegen/src/codegen/module_globals_emit.rs @@ -269,7 +269,7 @@ pub(crate) fn emit_module_globals( }; // Use default (external) linkage for ALL module globals. // `internal` linkage lets clang -O3 assume the global is - // never written by optnone functions (setjmp/try-catch), + // never written by optnone functions, // causing it to constant-fold reads to 0.0. With external // linkage, the optimizer can't make cross-TU assumptions. // The module-unique name (perry_global___N) diff --git a/crates/perry-codegen/src/eh_mode.rs b/crates/perry-codegen/src/eh_mode.rs index 7a1171ab36..6e6a0aaa64 100644 --- a/crates/perry-codegen/src/eh_mode.rs +++ b/crates/perry-codegen/src/eh_mode.rs @@ -1,41 +1,13 @@ -//! TEMPORARY exception-lowering mode switch (#7302). +//! Invoke-EH support (#7302): the nothrow-callee predicate for the +//! `invoke` conversion at the `LlBlock` call chokepoints, and the +//! render-time phi-predecessor rewrite for inline invoke block splits. //! -//! `PERRY_EH=invoke` lowers `try`/`catch` (and the async rejection boundary) -//! to `invoke`/`landingpad` with `js_throw` raising through the Itanium -//! unwinder; unset / `PERRY_EH=setjmp` keeps the setjmp/longjmp lowering. -//! -//! This flag exists ONLY for bisection while the invoke lowering is -//! validated. It is deleted — together with the entire setjmp path -//! (`volatile_setjmp.rs`, `setjmp_abi.rs`, `returns_twice`/`#1` handling) — -//! when the default flips. A permanent hybrid is the failure mode #7302 -//! exists to remove; do not build on this switch. -//! -//! The value participates in the object-cache key -//! (`perry/src/commands/compile/object_cache.rs`) — the two modes emit -//! structurally different IR for every function containing a `try`. +//! Perry lowers `try`/`catch` to `invoke`/`landingpad` (SEH funclets on +//! windows-msvc) — see `stmt/try_stmt.rs` for the lowering and +//! `perry-runtime/src/eh.rs` for the throw side. use std::collections::HashMap; -use std::sync::OnceLock; - -pub(crate) fn invoke_eh_enabled() -> bool { - static ENABLED: OnceLock = OnceLock::new(); - *ENABLED.get_or_init(|| { - matches!( - std::env::var("PERRY_EH").as_deref(), - Ok("invoke") | Ok("INVOKE") | Ok("1") - ) - }) -} -/// Runtime helpers that participate in the EH machinery itself and are -/// verified never to reach `js_throw` — they stay plain `call`s inside a -/// protected region (an `invoke` on them would be legal but wires the EH -/// bookkeeping into its own landing pads, which is both noise and, for the -/// catch-entry sequence, a self-referential shape). -/// -/// `js_shadow_*` (GC shadow-stack bookkeeping: TLS pushes/pops/stores) and -/// `js_gc_*` (collection entry points) cannot throw JS by construction — -/// the GC has no throw path; allocation failure is a Rust abort. /// Rewrite `phi` predecessor labels after inline invoke splits (#7302). /// /// An `invoke` emitted mid-block is followed by an inline `eh.contN:` label, @@ -98,6 +70,15 @@ fn flush_left_label(line: &str) -> Option<&str> { .then_some(rest) } +/// Runtime helpers that participate in the EH machinery itself and are +/// verified never to reach `js_throw` — they stay plain `call`s inside a +/// protected region (an `invoke` on them would be legal but wires the EH +/// bookkeeping into its own landing pads, which is both noise and, for the +/// catch-entry sequence, a self-referential shape). +/// +/// `js_shadow_*` (GC shadow-stack bookkeeping: TLS pushes/pops/stores) and +/// `js_gc_*` (collection entry points) cannot throw JS by construction — +/// the GC has no throw path; allocation failure is a Rust abort. pub(crate) fn callee_is_nothrow(name: &str) -> bool { name.starts_with("llvm.") || name.starts_with("js_shadow_") diff --git a/crates/perry-codegen/src/expr/fs_await.rs b/crates/perry-codegen/src/expr/fs_await.rs index 0bc1f2f826..5e7b4d37d1 100644 --- a/crates/perry-codegen/src/expr/fs_await.rs +++ b/crates/perry-codegen/src/expr/fs_await.rs @@ -228,7 +228,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // settle the caller's promise as rejected — not unwind. Without // this, `async function f() { await Promise.reject(e); }` // would terminate the process because `js_throw` longjmps - // through a non-existent setjmp frame. + // through a non-existent handler frame. ctx.current_block = reject_idx; let promise_handle3 = unbox_to_i64(ctx.block(), &promise_box); let reason = ctx diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 06c5f1c25d..0796acc146 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -606,7 +606,7 @@ pub(crate) struct FnCtx<'a> { /// decremented after. `Stmt::Return` emits `js_try_end()` this many /// times before the actual `ret` so the runtime's TRY_DEPTH counter /// stays balanced — without this, an early `return` inside a try - /// body leaks one slot in the runtime's setjmp jump-buffer table + /// body leaks one slot in the runtime's handler stack /// per call. Once 128 leaks accumulate the runtime panics with /// "Try block nesting too deep". pub try_depth: usize, diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index 247655031b..40bf071dd2 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -16,23 +16,6 @@ pub struct LlFunction { /// Optional LLVM linkage string, e.g. `"internal"` or `"private"`. Empty /// string means external (default) linkage. pub linkage: String, - /// When true, the function body contains a `try` statement (setjmp/longjmp), - /// so the definition gets `#1` (`noinline`) and `to_ir` runs the volatile - /// promotion pass. - /// - /// The setjmp hazard: `longjmp` restores the callee-saved registers and - /// stack pointer that `setjmp` snapshotted, so any local LLVM parked in a - /// register across the setjmp call reverts to its setjmp-time value when - /// the exception fires — the try body's mutations vanish in the catch. - /// `returns_twice` on the setjmp call is not sufficient at -O2 on aarch64. - /// - /// This used to be handled by stamping `optnone` on the whole function, - /// which is correct (at -O0 every value is frame-resident) but cost ~5x on - /// the surrounding code even when nothing ever throws (#6385). We now apply - /// C's `volatile` rule precisely instead: only the allocas the try body - /// actually stores into get volatile accesses, and everything else in the - /// function stays optimizable. See [`crate::volatile_setjmp`]. - pub has_try: bool, /// When true, emit `alwaysinline` attribute. Forces LLVM to inline this /// function at every call site, exposing integer operations to the /// caller's optimizer context (critical for vectorization of clamp patterns). @@ -45,16 +28,13 @@ pub struct LlFunction { /// gets inlined into its loop without the binary-size blowup an /// unconditional `alwaysinline` threshold bump causes. See the /// inline-hot-small heuristic in `codegen/function.rs`. `alwaysinline` - /// already implies the hint, so the two are never emitted together, and - /// `has_try` (noinline) still wins over both in `to_ir`. + /// already implies the hint, so the two are never emitted together. pub inline_hint: bool, /// Invoke-EH (#7302): this function contains landing pads (Itanium) or /// funclet pads (SEH), so its `define` line must carry /// `personality ptr @` — `perry_eh_personality` on Mach-O/ELF, - /// `__C_specific_handler` on windows-msvc. Set by the invoke-mode - /// try/async-boundary dispatch (which knows the target triple); - /// orthogonal to `has_try` (the setjmp-era noinline/volatile machinery, - /// which stays false in invoke mode). + /// `__C_specific_handler` on windows-msvc. Set by the try/async-boundary + /// dispatch (which knows the target triple). pub personality: Option<&'static str>, blocks: Vec, block_counter: u32, @@ -219,7 +199,6 @@ impl LlFunction { return_type, params, linkage: String::new(), - has_try: false, force_inline: false, inline_hint: false, personality: None, @@ -403,24 +382,6 @@ impl LlFunction { self.pre_return_void_calls.push(func_name.into()); } - /// Open a setjmp-protected region (#6385). Every `store` emitted into any - /// block of this function until the matching [`exit_try_region`] is - /// recorded as "modified between the setjmp and a possible longjmp", and - /// the alloca behind it is given volatile accesses by `to_ir`. - /// - /// Call this around the lowering of a `try` body and of a `catch` body - /// that a `finally` re-protects — i.e. exactly the code that a `longjmp` - /// can cut short. Regions nest; the depth counter handles that. - /// - /// [`exit_try_region`]: LlFunction::exit_try_region - pub fn enter_try_region(&self) { - self.reg_counter.enter_try_region(); - } - - pub fn exit_try_region(&self) { - self.reg_counter.exit_try_region(); - } - /// Invoke-EH (#7302): enter/leave a handler scope. While a scope is /// active, every potentially-throwing call any block of this function /// emits carries an unwind edge to the scope's landing-pad label. @@ -620,12 +581,7 @@ impl LlFunction { format!("{} ", self.linkage) }; - let attrs = if self.has_try { - // noinline (setjmp/volatile/async-rejecting boundary) always wins, - // even if an inline attribute was optimistically set before body - // lowering discovered the try. - " #1" - } else if self.force_inline { + let attrs = if self.force_inline { " alwaysinline" } else if self.inline_hint { " inlinehint" @@ -741,22 +697,6 @@ impl LlFunction { ir }; - // setjmp volatile promotion (#6385). - // - // Runs LAST so it sees every instruction, including the ones the - // return-site rewrite above just spliced in. Any alloca this function - // stores into between a `setjmp` and its `longjmp` (recorded by - // `LlBlock::emit` while a try region was open) gets `volatile` loads - // and stores, which is what stops mem2reg/SROA from promoting it into - // a register that `longjmp` would revert. This replaces the old - // `optnone`-the-whole-function hammer. - if self.has_try { - let try_stores = self.reg_counter.try_region_stores(); - if !try_stores.is_empty() { - return crate::volatile_setjmp::apply_setjmp_volatile(&ir, &try_stores); - } - } - // Invoke-EH (#7302): inline invoke splits move a block's true CFG // tail behind `eh.contN:` labels; phi incoming-edge labels captured // at emit time must follow. Runs last so it sees the spliced text. diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 9a942189de..279b3ab63a 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -24,7 +24,6 @@ pub(crate) mod native_value; pub(crate) mod nm_install; pub mod opt_report; pub mod runtime_decls; -pub(crate) mod setjmp_abi; pub(crate) mod stmt; pub mod strings; pub mod stubs; @@ -35,7 +34,6 @@ pub(crate) mod type_analysis_facts; pub(crate) mod type_analysis_net; pub(crate) mod typed_shape; pub mod types; -pub(crate) mod volatile_setjmp; pub use codegen::{ compile_module, resolve_target_triple, AppMetadata, CompileOptions, FpContractMode, diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index 0395f21983..b29a70eb82 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -97,7 +97,7 @@ fn promote_global_for_units(line: &str) -> String { /// * Anything that can allocate or trigger GC gets NO group — the moving /// GC's shadow-stack reload discipline depends on those calls staying /// maximally clobbering. -/// * Anything that can reach `js_throw` (setjmp/longjmp) gets NO group — +/// * Anything that can reach `js_throw` (raises through the unwinder) gets NO group — /// `willreturn` would let DCE delete a throwing call whose result is /// unused, silently dropping the exception. /// @@ -166,12 +166,7 @@ fn declare_line_for(f: &LlFunction) -> String { .map(|(t, _)| t.to_string()) .collect::>() .join(", "); - let attrs = if f.name == "setjmp" || f.name == "_setjmp" { - " #0" - } else { - "" - }; - format!("declare {} @{}({}){}", f.return_type, f.name, params, attrs) + format!("declare {} @{}({})", f.return_type, f.name, params) } /// Render a function with external linkage forced, promoting an `internal` / @@ -256,21 +251,11 @@ impl LlModule { } self.declared_names.insert(name.to_string()); let param_str = param_types.join(", "); - // setjmp needs the `returns_twice` attribute to prevent - // LLVM from promoting alloca slots to SSA registers across - // the setjmp boundary. Without it, local variables modified - // between setjmp and longjmp are clobbered when the second - // return (via longjmp) happens. - // // Verified-pure runtime helpers get the #2/#3 optimization groups // (#6082) — see `helper_decl_attrs` for the audit invariants. The // lookup is name-keyed here in the single declaration funnel so // every declaration path agrees on the attributes. - let attrs = if name == "setjmp" || name == "_setjmp" { - " #0" - } else { - helper_decl_attrs(name) - }; + let attrs = helper_decl_attrs(name); self.declarations.push(( name.to_string(), format!("declare {} @{}({}){}", return_type, name, param_str, attrs), @@ -540,33 +525,6 @@ impl LlModule { /// same attributes and metadata (so `#0`/`#1` and `!N` references resolve in /// every unit). Over-emitting an unused attribute group is harmless. fn push_attrs_and_metadata(&self, ir: &mut String) { - // Attribute group for setjmp's `returns_twice` marker. Only emit if - // setjmp (any variant) was declared. Apple declares `_setjmp`, Windows - // `_setjmp` (2-arg ABI), Linux `setjmp` — all need `returns_twice`. - if self.declared_names.contains("setjmp") || self.declared_names.contains("_setjmp") { - ir.push_str("\nattributes #0 = { returns_twice }\n"); - // Functions containing a `try` are marked `#1`. - // - // This group used to carry `optnone` as well, to stop mem2reg/SROA - // from promoting allocas across the setjmp call (a promoted local - // lives in a callee-saved register, which `longjmp` restores to its - // setjmp-time value — so try-body mutations were invisible to the - // catch). That worked, but it deoptimized the ENTIRE function: just - // having a `try` cost ~5x on the surrounding loop even when nothing - // ever threw (#6385). - // - // The promotion is now blocked surgically instead, by emitting - // `volatile` loads/stores for exactly the allocas the try body - // writes (see `crate::volatile_setjmp`) — LLVM refuses to promote - // an alloca with any volatile access. Everything else optimizes. - // - // `noinline` stays. LLVM's `isInlineViable` already refuses to - // inline a function that contains a `returns_twice` call, so this - // is belt-and-braces rather than load-bearing — but it keeps the - // setjmp frame's identity from depending on an internal inliner - // policy, at zero cost. - ir.push_str("attributes #1 = { noinline }\n"); - } // Verified runtime-helper groups (#6082) — emitted only when a // declaration actually references them (mirrors the setjmp gating // above). See `helper_decl_attrs` for the audit invariants. diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index bb3badf2a0..472589e1e5 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -523,7 +523,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_fs_access_sync", I32, &[DOUBLE]); module.declare_function("js_fs_access_sync_mode", I32, &[DOUBLE, DOUBLE]); // fs.accessSync(path) — Node-compatible variant that throws on - // failure (via js_throw → setjmp longjmp). Returns NaN-boxed undefined. + // failure (via js_throw → unwind). Returns NaN-boxed undefined. module.declare_function("js_fs_access_sync_throw", DOUBLE, &[DOUBLE]); module.declare_function("js_fs_access_sync_throw_mode", DOUBLE, &[DOUBLE, DOUBLE]); // fs.realpathSync(path) — returns raw *mut StringHeader i64. diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index dbc4096919..ff2fd7c670 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -719,37 +719,22 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { module.declare_function("js_unsettled_top_level_await_exit", VOID, &[]); module.declare_function("js_throw", VOID, &[DOUBLE]); - // Exception handling (Phase G): setjmp/longjmp-based try/catch. - // js_try_push() returns a ptr to a jmp_buf. - // setjmp(ptr) returns i32 (0 on first call, non-0 after longjmp). + // Exception handling: invoke/landingpad-based try/catch (#7302). + // js_eh_try_push() arms a handler (savepoint recording). // js_try_end() pops the try depth (no return value). // js_get_exception() returns the thrown NaN-boxed value. // js_clear_exception() resets the exception state. // js_has_exception() returns i32 (1 if exception is active, 0 otherwise). // js_enter_finally() / js_leave_finally() bracket finally blocks. - if crate::eh_mode::invoke_eh_enabled() { - // Invoke-EH (#7302): handlers are armed by js_eh_try_push (savepoints - // only, no jmp_buf) and entered through landing pads (Itanium) or - // catchpads (SEH on windows-msvc — same target-triple rule as the - // setjmp ABI selection below). No setjmp is declared — which also - // keeps the `#0`/`#1` attribute groups out of the module. - module.declare_function("js_eh_try_push", VOID, &[]); - if module.target_triple.contains("-windows-") { - module.declare_seh_machinery(); - } else { - module.declare_personality(); - } + // Invoke-EH (#7302): handlers are armed by js_eh_try_push (savepoints + // only, no jmp_buf) and entered through landing pads (Itanium) or + // catchpads (SEH on windows-msvc — decided by the TARGET triple, not + // host cfg!, so cross-compiles emit the target's EH shape). + module.declare_function("js_eh_try_push", VOID, &[]); + if module.target_triple.contains("-windows-") { + module.declare_seh_machinery(); } else { - module.declare_function("js_try_push", PTR, &[]); - // setjmp variant selection: decided by `crate::setjmp_abi` from the - // compile target's LLVM triple (`module.target_triple`), NOT host - // `cfg!` — cross-compiles must declare the *target's* setjmp ABI - // (Windows MSVC 2-arg `_setjmp`, Apple fast 1-arg `_setjmp`, plain - // `setjmp` elsewhere; full rationale in `crate::setjmp_abi`). The - // same `SetjmpAbi` drives the call sites in `stmt/try_stmt.rs`, so - // the declaration and the calls can never disagree on name or arity. - let abi = crate::setjmp_abi::setjmp_abi_for_triple(&module.target_triple); - module.declare_function(abi.callee(), I32, abi.param_types()); + module.declare_personality(); } module.declare_function("js_try_end", VOID, &[]); module.declare_function("js_get_exception", DOUBLE, &[]); diff --git a/crates/perry-codegen/src/setjmp_abi.rs b/crates/perry-codegen/src/setjmp_abi.rs deleted file mode 100644 index 8054bdfe2d..0000000000 --- a/crates/perry-codegen/src/setjmp_abi.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! Target-dependent setjmp ABI selection for try/catch lowering. -//! -//! Perry's try/catch (and the async rejection boundary) is setjmp/longjmp -//! based, and the setjmp *variant* differs per target: -//! -//! - Windows MSVC: `_setjmp(jmp_buf, frame_pointer)` — MSVCRT exports only -//! `_setjmp`/`_setjmpex` (there is no plain `setjmp` symbol), and the x64 -//! intrinsic takes the frame pointer as a second argument (we pass null -//! to opt out of SEH unwinding through the frame). -//! - Apple: the fast `_setjmp(jmp_buf)` (LLVM-IR name `_setjmp` → Mach-O -//! symbol `__setjmp`), which skips the sigprocmask/sigaltstack syscalls -//! the default `setjmp(3)` performs on Darwin (~43% of CPU on -//! promise_all_chains.ts before the swap). Perry never longjmps out of a -//! signal handler, so the fast variant is functionally equivalent. -//! - Everything else (Linux glibc/musl, Android, OHOS): plain -//! `setjmp(jmp_buf)` — glibc's `setjmp(3)` already skips the signal -//! mask, so no swap is needed. -//! -//! The variant MUST be derived from the *compile target's* LLVM triple, not -//! from host `cfg!`. The host `cfg!` version of this decision meant -//! cross-compiling `--target windows` from Linux emitted `@setjmp` (LNK2019: -//! MSVCRT has no `setjmp` export), from macOS emitted the 1-arg `_setjmp` -//! (the MSVC x64 second argument — RDX — was garbage, so longjmp corrupted), -//! and a Windows host emitted the 2-arg Windows form into linux/macos -//! objects. Host-native compiles were always correct because -//! `default_target_triple()` (the `CompileOptions.target == None` fallback) -//! is the host triple — deriving from the effective triple preserves that -//! behavior bit-for-bit. Same principle as `crate::target_layout`. -//! -//! Every consumer — the try/catch call site, the async-boundary call site, -//! and the extern prototype in `runtime_decls` — must go through this one -//! type so the call and its declaration can never disagree on the callee -//! name or arity (that would be an LLVM verifier error, or silent stack -//! corruption). - -use crate::types::{LlvmType, PTR}; - -/// Which setjmp flavor the emitted IR calls (and declares). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum SetjmpAbi { - /// `_setjmp(ptr jmp_buf, ptr frame_ptr)` — Windows MSVC x64. - WindowsMsvc, - /// `_setjmp(ptr jmp_buf)` — Apple's fast (no-sigprocmask) variant. - AppleFast, - /// `setjmp(ptr jmp_buf)` — default C ABI (Linux glibc/musl, Android, …). - CSetjmp, -} - -/// Select the setjmp ABI for an LLVM target triple. `triple` is the -/// *effective* triple for the compile — `CompileOptions.target` when -/// `--target` was given, else `default_target_triple()` (the host) — so a -/// host-native compile keeps today's behavior and a cross-compile follows -/// the target. -pub(crate) fn setjmp_abi_for_triple(triple: &str) -> SetjmpAbi { - if triple.contains("-windows-") { - SetjmpAbi::WindowsMsvc - } else if triple.contains("-apple-") { - SetjmpAbi::AppleFast - } else { - SetjmpAbi::CSetjmp - } -} - -impl SetjmpAbi { - /// LLVM-IR callee name (also what `runtime_decls` declares). - pub(crate) fn callee(self) -> &'static str { - match self { - SetjmpAbi::WindowsMsvc | SetjmpAbi::AppleFast => "_setjmp", - SetjmpAbi::CSetjmp => "setjmp", - } - } - - /// Parameter types for the extern declaration. Must stay in lock-step - /// with [`Self::call_instruction`] — the divergence test below enforces - /// the arity. - pub(crate) fn param_types(self) -> &'static [LlvmType] { - match self { - SetjmpAbi::WindowsMsvc => &[PTR, PTR], - SetjmpAbi::AppleFast | SetjmpAbi::CSetjmp => &[PTR], - } - } - - /// The full call-site instruction. `#0` is the shared `returns_twice` - /// attribute group emitted by `LlModule` whenever a setjmp variant is - /// declared — it must be on the call site too, or LLVM -O2 promotes - /// allocas across the setjmp and the longjmp path reads stale values. - pub(crate) fn call_instruction(self, result_reg: &str, jmpbuf: &str) -> String { - match self { - SetjmpAbi::WindowsMsvc => format!( - "{} = call i32 @_setjmp(ptr {}, ptr null) #0", - result_reg, jmpbuf - ), - SetjmpAbi::AppleFast => { - format!("{} = call i32 @_setjmp(ptr {}) #0", result_reg, jmpbuf) - } - SetjmpAbi::CSetjmp => { - format!("{} = call i32 @setjmp(ptr {}) #0", result_reg, jmpbuf) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn windows_target_uses_two_arg_underscore_setjmp() { - let abi = setjmp_abi_for_triple("x86_64-pc-windows-msvc"); - assert_eq!(abi, SetjmpAbi::WindowsMsvc); - assert_eq!(abi.callee(), "_setjmp"); - assert_eq!(abi.param_types(), &[PTR, PTR]); - assert_eq!( - abi.call_instruction("%r7", "%r6"), - "%r7 = call i32 @_setjmp(ptr %r6, ptr null) #0" - ); - } - - #[test] - fn apple_targets_use_fast_one_arg_underscore_setjmp() { - for triple in [ - "arm64-apple-macosx15.0.0", - "x86_64-apple-macosx15.0.0", - "aarch64-apple-darwin", - "aarch64-apple-ios", - "arm64-apple-ios17.0-simulator", - "aarch64-apple-tvos", - "aarch64-apple-watchos", - "arm64_32-apple-watchos", - "arm64-apple-xros1.0", - ] { - let abi = setjmp_abi_for_triple(triple); - assert_eq!(abi, SetjmpAbi::AppleFast, "triple: {}", triple); - assert_eq!(abi.callee(), "_setjmp"); - assert_eq!(abi.param_types(), &[PTR]); - assert_eq!( - abi.call_instruction("%r7", "%r6"), - "%r7 = call i32 @_setjmp(ptr %r6) #0" - ); - } - } - - #[test] - fn linux_family_targets_use_plain_setjmp() { - for triple in [ - "x86_64-unknown-linux-gnu", - "aarch64-unknown-linux-gnu", - "x86_64-unknown-linux-musl", - "aarch64-unknown-linux-musl", - "aarch64-unknown-linux-android", - "x86_64-unknown-linux-android", - "aarch64-unknown-linux-ohos", - ] { - let abi = setjmp_abi_for_triple(triple); - assert_eq!(abi, SetjmpAbi::CSetjmp, "triple: {}", triple); - assert_eq!(abi.callee(), "setjmp"); - assert_eq!(abi.param_types(), &[PTR]); - assert_eq!( - abi.call_instruction("%r7", "%r6"), - "%r7 = call i32 @setjmp(ptr %r6) #0" - ); - } - } - - /// The declaration arity and the call-site arity must never diverge — - /// that's an IR verifier error (or, worse, a silently-garbage RDX on - /// MSVC x64). Count the `ptr ` argument slots in the emitted call and - /// compare against the declared parameter list, per variant. - #[test] - fn call_arity_matches_declared_arity_for_every_variant() { - for abi in [ - SetjmpAbi::WindowsMsvc, - SetjmpAbi::AppleFast, - SetjmpAbi::CSetjmp, - ] { - let call = abi.call_instruction("%r1", "%r0"); - let arg_count = call.matches("ptr ").count(); - assert_eq!( - arg_count, - abi.param_types().len(), - "call/declaration arity diverged for {:?}: {}", - abi, - call - ); - assert!( - call.contains(&format!("@{}(", abi.callee())), - "call names a different callee than the declaration for {:?}: {}", - abi, - call - ); - } - } -} diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index de6b42cf4d..ee69bd8a34 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -83,8 +83,6 @@ fn lower_async_rejecting_stmts_inner( // machines still need the ECMAScript async boundary: any abrupt // completion before the first await rejects the returned Promise instead // of escaping as a host exception. - let invoke_eh = crate::eh_mode::invoke_eh_enabled(); - let body_idx = ctx.new_block("async.body"); let catch_idx = ctx.new_block("async.catch"); let merge_idx = ctx.new_block("async.merge"); @@ -93,39 +91,17 @@ fn lower_async_rejecting_stmts_inner( let catch_label = ctx.block_label(catch_idx); let merge_label = ctx.block_label(merge_idx); - // Handler dispatch — shared with `lower_try` so the mechanism (invoke - // landing pad, or the target-ABI setjmp variant chosen from - // `ctx.target_triple` via `crate::setjmp_abi`) is decided in exactly one - // place per mode. - let eh_scope = if invoke_eh { - Some(try_stmt::emit_eh_dispatch(ctx, &catch_label, &body_label)) - } else { - ctx.func.has_try = true; - try_stmt::emit_setjmp_dispatch(ctx, &catch_label, &body_label); - None - }; + // Handler dispatch — shared with `lower_try` so the per-triple shape + // (Itanium landing pad vs SEH funclet) is decided in exactly one place. + // The landing pad reads no locals; the unwind edges keep SSA values live + // where LLVM's ordinary EH liveness says so. + let lpad = try_stmt::emit_eh_dispatch(ctx, &catch_label, &body_label); ctx.current_block = body_idx; ctx.try_depth += 1; - // Setjmp mode: the whole async body runs between the setjmp above and a - // possible longjmp into `async.catch`. `async.catch` itself only touches - // runtime state (get/clear exception, reject the promise) and never reads - // a local, so in principle no alloca needs to survive that longjmp — but - // we open the region anyway rather than special-case it. The uniform rule - // ("every alloca stored inside a setjmp-protected region is volatile") is - // the one that is trivially sound, and this is still strictly better than - // the `optnone` it replaces. Invoke mode needs none of that: the landing - // pad reads no locals, and the unwind edges keep SSA values live where - // LLVM's ordinary EH liveness says so. - match &eh_scope { - Some(lpad) => ctx.func.push_eh_scope(lpad.clone()), - None => ctx.func.enter_try_region(), - } + ctx.func.push_eh_scope(lpad); lower_stmts_inner(ctx, stmts, emit_shadow_clears)?; - match &eh_scope { - Some(_) => ctx.func.pop_eh_scope(), - None => ctx.func.exit_try_region(), - } + ctx.func.pop_eh_scope(); ctx.try_depth -= 1; if !ctx.block().is_terminated() { ctx.block().call_void("js_try_end", &[]); @@ -548,11 +524,11 @@ pub(crate) fn lower_stmt(ctx: &mut FnCtx<'_>, stmt: &Stmt) -> Result<()> { Ok(()) } - // Phase G: real setjmp/longjmp-based exception handling. - // // `throw expr` evaluates the expression, calls js_throw(value) - // which longjmps to the most recent try block, and emits an - // LLVM `unreachable` terminator (js_throw never returns). + // which raises through the unwinder to the innermost handler + // (#7302; the call becomes an `invoke` when a handler scope is + // active), and emits an LLVM `unreachable` terminator (js_throw + // never returns). // // Spec-corner: inside an async function with no enclosing // `try { ... }` frame, a thrown value must reject the returned @@ -577,36 +553,9 @@ pub(crate) fn lower_stmt(ctx: &mut FnCtx<'_>, stmt: &Stmt) -> Result<()> { Ok(()) } - // Phase G: try/catch/finally via setjmp/longjmp. - // - // CFG shape: - // : - // %jmpbuf = call ptr @js_try_push() - // %sjr = call i32 @setjmp(ptr %jmpbuf) - // %is_exc = icmp ne i32 %sjr, 0 - // br i1 %is_exc, label %catch_entry, label %try_body - // - // try_body: - // - // call void @js_try_end() - // br label %finally_or_merge - // - // catch_entry: - // call void @js_try_end() ; pop try depth before catch body - // %exc = call double @js_get_exception() - // call void @js_clear_exception() - // - // - // br label %finally_or_merge - // - // finally_or_merge: - // - // - // - // Local variable safety: all locals are alloca-backed (stack slots), - // not SSA registers, so they survive longjmp without explicit - // save/restore. This is the key advantage of the alloca+mem2reg - // strategy used by our LLVM backend. + // try/catch/finally via invoke/landingpad (#7302) — see + // `stmt/try_stmt.rs` for the CFG shape and the per-triple + // dispatch (Itanium landing pads / SEH funclets). Stmt::Try { body, catch, diff --git a/crates/perry-codegen/src/stmt/try_stmt.rs b/crates/perry-codegen/src/stmt/try_stmt.rs index 6608296139..85692a42f0 100644 --- a/crates/perry-codegen/src/stmt/try_stmt.rs +++ b/crates/perry-codegen/src/stmt/try_stmt.rs @@ -1,75 +1,55 @@ -//! `Stmt::Try` lowering — setjmp/longjmp-based exception handling. +//! `Stmt::Try` lowering — LLVM `invoke`/`landingpad` exception handling +//! (#7302; SEH funclets on windows-msvc). +//! +//! The CFG pattern: +//! 1. `js_eh_try_push()` arms the handler (savepoint recording only — no +//! jmp_buf; the runtime's `js_throw` raises through the unwinder and +//! the frame's unwind tables carry the rest). +//! 2. Branch into the try body. While the body lowers, its landing-pad +//! label is the active EH scope: every potentially-throwing call the +//! body emits becomes an `invoke` unwinding there +//! (`LlBlock::eh_invoke_suffix`). +//! 3. The landing pad funnels into the catch entry, which runs +//! `js_try_end` → `js_get_exception` → `js_clear_exception` and binds +//! the catch parameter. +//! 4. Catch/finally bodies lower under the *enclosing* scope, so a throw +//! escaping them wires to the outer handler — or leaves the function +//! when there is none. Re-raise sites (`js_throw` after a finally copy) +//! go through the same call chokepoint and pick up the correct edge +//! automatically. +//! +//! History: until #7302 this was setjmp/longjmp-based, which required +//! `returns_twice` + `noinline` on every try-containing function plus a +//! volatile-promotion pass over try-mutated allocas (#6385), and made +//! precise moving-GC roots unsound in try functions (a longjmp could skip a +//! statepoint relocation write-back — the motivating defect, #7174). use super::*; -/// Try/catch/finally via setjmp/longjmp. +/// Arm the handler and materialize the unwind-target block(s) that funnel +/// the exception into `exc_label`. Returns the unwind label; the caller +/// pushes it as the EH scope around the protected body. /// -/// The CFG pattern: -/// 1. Call js_try_push() to get a jmp_buf pointer -/// 2. Call setjmp(jmpbuf) — returns 0 on first call, non-0 after longjmp -/// 3. Branch: 0 → try_body, non-0 → catch_entry -/// 4. try_body runs, calls js_try_end(), branches to finally -/// 5. catch_entry calls js_try_end(). With a user `catch`: reads the -/// exception, runs catch, branches to finally. WITHOUT a `catch` -/// (a `try/finally` with no handler): captures the exception, runs -/// a dedicated copy of the finally body, then re-raises via -/// js_throw so the throw propagates instead of being swallowed. -/// 6. finally runs (if present), then falls through to merge (only the -/// normal-completion path reaches this merge finally) -/// Emit `js_try_push()` + setjmp in the CURRENT block, branching to -/// `exc_label` on a longjmp (exception) and `normal_label` otherwise. -/// -/// CRITICAL: setjmp must carry `returns_twice` on the call site too (not -/// just the declaration). Without it, LLVM -O2 promotes alloca-backed -/// locals to SSA registers and the longjmp return path sees stale -/// pre-setjmp values. The standard `blk.call()` doesn't support call -/// attributes, so the instruction is emitted manually. -/// -/// setjmp variant selection — decided by `crate::setjmp_abi` from the -/// compile target's LLVM triple (`ctx.target_triple`), NOT host `cfg!`, -/// so cross-compiles emit the target's ABI. The same `SetjmpAbi` drives -/// the extern declaration in `runtime_decls/strings_part2.rs`, so the -/// call and the prototype can't diverge. See `crate::setjmp_abi` for the -/// per-target rationale (Windows 2-arg `_setjmp`, Apple fast `_setjmp`, -/// plain `setjmp` elsewhere). -/// -/// Also used by the async rejection boundary in `stmt/mod.rs` -/// (`lower_async_rejecting_stmts_inner`) — same setjmp, different -/// exception continuation. -pub(super) fn emit_setjmp_dispatch(ctx: &mut FnCtx<'_>, exc_label: &str, normal_label: &str) { - use crate::types::{I32, PTR}; - let abi = crate::setjmp_abi::setjmp_abi_for_triple(ctx.target_triple); - let blk = ctx.block(); - let jmpbuf = blk.call(PTR, "js_try_push", &[]); - let sjr_reg = blk.next_reg(); - blk.emit_raw(abi.call_instruction(&sjr_reg, &jmpbuf)); - let is_exc = blk.icmp_ne(I32, &sjr_reg, "0"); - blk.cond_br(&is_exc, exc_label, normal_label); -} - -/// Invoke-EH (#7302) counterpart of [`emit_setjmp_dispatch`]: arm the -/// handler (`js_eh_try_push` — savepoints only, no jmp_buf), branch into -/// the protected body, and materialize the unwind-target block(s) that -/// funnel the exception into `exc_label`. Returns the unwind label; the -/// caller pushes it as the EH scope around the protected body so every -/// potentially-throwing call inside carries the unwind edge. -/// -/// Two per-triple shapes (same rule as `crate::setjmp_abi`: decided by the -/// TARGET triple, not host `cfg!`): +/// Two per-triple shapes (same rule as the old setjmp-ABI selection: decided +/// by the TARGET triple, not host `cfg!`): /// /// - Itanium (Mach-O/ELF): one landing-pad block — /// `landingpad {ptr,i32} catch ptr null` → `br %exc_label`. The pair is /// ignored; the thrown value is read back from the runtime's rooted TLS -/// slot via `js_get_exception`, exactly as the setjmp path did. +/// slot via `js_get_exception`. /// - SEH (windows-msvc): `catchswitch within none [pad] unwind to caller` → /// `catchpad [ptr @perry_seh_filter]` → `catchret to %exc_label`. The /// filter matches Perry's `RaiseException` code; foreign SEH exceptions -/// (access violations etc.) keep unwinding past JS handlers, matching the -/// setjmp path (which never caught them either). +/// (access violations etc.) keep unwinding past JS handlers. +/// +/// Savepoint restores run at throw time (`js_throw`), which is sound +/// because the unwinder skips Rust cleanups exactly like `longjmp` did (the +/// runtime is built panic=abort with forced unwind tables; see +/// `perry-runtime/src/eh.rs`). /// -/// Savepoint restores already ran at throw time (`js_throw`), which is -/// sound because the unwinder skips Rust cleanups just like `longjmp` did -/// (runtime built panic=abort; see `perry-runtime/src/eh.rs`). +/// Also used by the async rejection boundary in `stmt/mod.rs` +/// (`lower_async_rejecting_stmts_inner`) — same dispatch, different +/// exception continuation. pub(super) fn emit_eh_dispatch(ctx: &mut FnCtx<'_>, exc_label: &str, normal_label: &str) -> String { let msvc = ctx.target_triple.contains("-windows-"); ctx.func.personality = Some(if msvc { @@ -130,196 +110,6 @@ pub(crate) fn lower_try( body: &[perry_hir::Stmt], catch: Option<&perry_hir::CatchClause>, finally: Option<&[perry_hir::Stmt]>, -) -> Result<()> { - if crate::eh_mode::invoke_eh_enabled() { - return lower_try_invoke(ctx, body, catch, finally); - } - // Mark the enclosing function so IR emission adds `#1` (noinline) and - // runs the setjmp volatile-promotion pass. - // - // At -O2 on aarch64, LLVM's mem2reg/SROA would otherwise promote allocas - // to SSA registers across the setjmp call, and `longjmp` — which restores - // the callee-saved registers snapshotted by `setjmp` — would revert the - // mutations the try body made, so the catch block reads stale values. - // `returns_twice` on the setjmp call site alone is not sufficient. - // - // The fix is C's `volatile` rule, not `optnone`: the - // `enter_try_region`/`exit_try_region` brackets below record every store - // the try body emits, and `LlFunction::to_ir` gives just those allocas - // volatile accesses. Everything else in the function — loop counters, - // arithmetic, compares, branches — stays fully optimizable (#6385). - ctx.func.has_try = true; - - // Allocate blocks. - let try_body_idx = ctx.new_block("try.body"); - let catch_idx = ctx.new_block("try.catch"); - let finally_idx = ctx.new_block("try.finally"); - - let try_body_label = ctx.block_label(try_body_idx); - let catch_label = ctx.block_label(catch_idx); - let finally_label = ctx.block_label(finally_idx); - - // --- current block: setjmp dispatch --- - emit_setjmp_dispatch(ctx, &catch_label, &try_body_label); - - // --- try body --- - ctx.current_block = try_body_idx; - // Track that this try frame is open so any `return` inside the body - // pops it via `js_try_end` before falling through to the function's - // ret. Decremented after the body finishes lowering. - ctx.try_depth += 1; - // Everything lowered from here on runs between the setjmp above and a - // possible longjmp into `try.catch`, so its stores must survive that - // longjmp (#6385). - ctx.func.enter_try_region(); - lower_stmts(ctx, body)?; - ctx.func.exit_try_region(); - ctx.try_depth -= 1; - if !ctx.block().is_terminated() { - ctx.block().call_void("js_try_end", &[]); - ctx.block().br(&finally_label); - } - - // --- catch --- - ctx.current_block = catch_idx; - ctx.block().call_void("js_try_end", &[]); - if let Some(clause) = catch { - let exc = ctx.block().call(DOUBLE, "js_get_exception", &[]); - ctx.block().call_void("js_clear_exception", &[]); - // Bind the catch param (if any) to the exception value. - if let Some((id, _name)) = &clause.param { - // Slot lives in the entry block — a closure inside the - // catch body may capture the exception binding and get - // called from a sibling branch that the catch block - // doesn't dominate. - let slot = ctx.func.alloca_entry(DOUBLE); - ctx.locals.insert(*id, slot.clone()); - ctx.block().store(DOUBLE, &exc, &slot); - // #7209: BIND the slot the frame is already sized for. - // - // `collect_pointer_typed_locals` assigns the catch parameter an - // index — it is implicitly `Any`, i.e. pointer-possible — so - // `js_shadow_frame_enter`'s count already includes it. Nothing ever - // bound it, so `active[idx]` stayed false and the collector never - // dereferenced this alloca: the frame was sized for a root that did - // not exist. - // - // Sharper than an ordinary missing root, because - // `js_clear_exception()` two lines up has already dropped the - // RUNTIME's reference. From here the exception is reachable only - // through this alloca, and the catch body is arbitrary user code — - // so a precise-roots collection can SWEEP it, not merely move it. - // - // Emitted here rather than hoisted to entry setup precisely because - // the slot must not go active before the store: on the non-throwing - // path this alloca is never written, and an entry-hoisted bind - // would hand the root-word decoder uninitialized stack bytes. After - // the store is what `Stmt::Let` does for every ordinary local, and - // it reuses the RESERVED index rather than growing the frame. - crate::expr::emit_shadow_slot_bind_for_local(ctx, *id); - } - if let Some(f) = finally { - // Per spec TryStatement : try Block Catch Finally — a throw - // escaping the CATCH body must still run the finally, whose - // own abrupt completion (throw) replaces the pending one. - // Protect the catch body with its own frame: on a longjmp out - // of it, run a dedicated copy of the finally body, then - // re-raise the catch's exception (unless the finally itself - // terminated abruptly — its terminator stands). - // Refs test262 S12.14_A7_T2/T3, S12.14_A13_T3. - let cbody_idx = ctx.new_block("try.catch.body"); - let cfail_idx = ctx.new_block("try.catch.fail"); - let cbody_label = ctx.block_label(cbody_idx); - let cfail_label = ctx.block_label(cfail_idx); - emit_setjmp_dispatch(ctx, &cfail_label, &cbody_label); - - ctx.current_block = cbody_idx; - ctx.try_depth += 1; - // The catch body sits inside its OWN setjmp (the one just emitted): - // a throw escaping it longjmps to `try.catch.fail`, which re-runs - // the finally and reads locals. So its stores are also - // "modified between setjmp and longjmp" (#6385). - ctx.func.enter_try_region(); - lower_stmts(ctx, &clause.body)?; - ctx.func.exit_try_region(); - ctx.try_depth -= 1; - if !ctx.block().is_terminated() { - ctx.block().call_void("js_try_end", &[]); - ctx.block().br(&finally_label); - } - - ctx.current_block = cfail_idx; - ctx.block().call_void("js_try_end", &[]); - let exc2 = ctx.block().call(DOUBLE, "js_get_exception", &[]); - lower_stmts(ctx, f)?; - if !ctx.block().is_terminated() { - ctx.block().call_void("js_throw", &[(DOUBLE, &exc2)]); - ctx.block().unreachable(); - } - } else { - lower_stmts(ctx, &clause.body)?; - if !ctx.block().is_terminated() { - ctx.block().br(&finally_label); - } - } - } else { - // No catch clause: this is a `try { ... } finally { ... }` - // (or a bare `try { ... } finally {}`). The longjmp landed - // here because the try body threw. ECMAScript requires the - // finally to run and then the ORIGINAL exception to RE-PROPAGATE - // — it must NOT be swallowed. Previously this block only did - // `js_try_end()` + fell through to the shared merge finally and - // the function returned `undefined`, silently eating the throw. - // - // Issue #37 / effect's `internalCall` "forced" path: - // `try { return body() } finally {}` swallowed body()'s throw, - // surfacing as `(FiberFailure) Error: {}`. - // - // Capture the pending exception BEFORE running finally (the - // finally body may touch exception state), run a dedicated copy - // of the finally body on this exception path, then re-raise via - // js_throw — unless the finally itself completed abruptly (a - // `return`/`throw` inside finally overrides the pending - // exception, per spec), in which case its own terminator stands. - let exc = ctx.block().call(DOUBLE, "js_get_exception", &[]); - if let Some(f) = finally { - lower_stmts(ctx, f)?; - } - if !ctx.block().is_terminated() { - ctx.block().call_void("js_throw", &[(DOUBLE, &exc)]); - ctx.block().unreachable(); - } - } - - // --- finally / merge (normal-completion path) --- - ctx.current_block = finally_idx; - if let Some(f) = finally { - lower_stmts(ctx, f)?; - } - Ok(()) -} - -/// Invoke-EH lowering of `Stmt::Try` (#7302). Structurally the same CFG as -/// the setjmp version — the differences are the transport, not the shape: -/// -/// 1. `js_eh_try_push()` arms the handler (savepoints, no jmp_buf) and the -/// body is entered by a plain branch — no setjmp, no `returns_twice`, -/// no volatile promotion, no `noinline`. -/// 2. While the body lowers, its landing-pad label is the active EH scope: -/// every potentially-throwing call becomes an `invoke` unwinding there. -/// 3. The landing pad funnels into the same catch-entry sequence the -/// setjmp path used (`js_try_end` → `js_get_exception` → -/// `js_clear_exception`). -/// 4. Catch/finally bodies lower under the *enclosing* scope (the inner -/// scope is popped first), so a throw escaping them wires to the outer -/// handler — or leaves the function entirely when there is none. The -/// re-raise sites (`js_throw` after a finally copy) go through the same -/// chokepoint and pick up the correct edge automatically. -pub(crate) fn lower_try_invoke( - ctx: &mut FnCtx<'_>, - body: &[perry_hir::Stmt], - catch: Option<&perry_hir::CatchClause>, - finally: Option<&[perry_hir::Stmt]>, ) -> Result<()> { let try_body_idx = ctx.new_block("try.body"); let catch_idx = ctx.new_block("try.catch"); @@ -335,7 +125,7 @@ pub(crate) fn lower_try_invoke( // --- try body (scope active) --- ctx.current_block = try_body_idx; // Return/break/continue inside the body pop the handler via js_try_end - // before leaving — same bookkeeping as the setjmp path. + // before leaving (see `Stmt::Return` in stmt/mod.rs). ctx.try_depth += 1; ctx.func.push_eh_scope(lpad_label); lower_stmts(ctx, body)?; @@ -352,21 +142,30 @@ pub(crate) fn lower_try_invoke( if let Some(clause) = catch { let exc = ctx.block().call(DOUBLE, "js_get_exception", &[]); ctx.block().call_void("js_clear_exception", &[]); + // Bind the catch param (if any) to the exception value. if let Some((id, _name)) = &clause.param { - // Entry-block slot + shadow-slot bind: identical to the setjmp - // path (#7209 — after js_clear_exception this alloca is the only - // root keeping the exception alive, and the bind must follow the - // store so the root-word decoder never sees uninitialized bytes). + // Slot lives in the entry block — a closure inside the catch + // body may capture the exception binding and get called from a + // sibling branch that the catch block doesn't dominate. + // + // #7209: the shadow-slot BIND must follow the store — after + // js_clear_exception this alloca is the only root keeping the + // exception alive, and an entry-hoisted bind would hand the + // root-word decoder uninitialized stack bytes on the + // non-throwing path. let slot = ctx.func.alloca_entry(DOUBLE); ctx.locals.insert(*id, slot.clone()); ctx.block().store(DOUBLE, &exc, &slot); crate::expr::emit_shadow_slot_bind_for_local(ctx, *id); } if let Some(f) = finally { - // Spec: a throw escaping the CATCH body must still run the - // finally, whose own abrupt completion replaces the pending one. - // Protect the catch body with its own handler; its landing pad - // runs a dedicated finally copy and re-raises. + // Per spec TryStatement : try Block Catch Finally — a throw + // escaping the CATCH body must still run the finally, whose own + // abrupt completion (throw) replaces the pending one. Protect + // the catch body with its own handler; its landing pad runs a + // dedicated copy of the finally body, then re-raises the + // catch's exception (unless the finally itself terminated + // abruptly — its terminator stands). // Refs test262 S12.14_A7_T2/T3, S12.14_A13_T3. let cbody_idx = ctx.new_block("try.catch.body"); let cfail_idx = ctx.new_block("try.catch.fail"); @@ -400,11 +199,15 @@ pub(crate) fn lower_try_invoke( } } } else { - // try/finally with no catch: run the finally copy on the exception - // path, then RE-RAISE the original exception (it must not be - // swallowed — issue #37). Capture before the finally body, which may - // touch exception state; a `return`/`throw` inside the finally - // overrides the pending exception per spec (its terminator stands). + // No catch clause: `try { ... } finally { ... }`. ECMAScript + // requires the finally to run and then the ORIGINAL exception to + // RE-PROPAGATE — it must NOT be swallowed (issue #37: effect's + // `internalCall` "forced" path). Capture the pending exception + // BEFORE running finally (the finally body may touch exception + // state), run a dedicated copy of the finally body on this + // exception path, then re-raise via js_throw — unless the finally + // itself completed abruptly (a `return`/`throw` inside finally + // overrides the pending exception, per spec). let exc = ctx.block().call(DOUBLE, "js_get_exception", &[]); if let Some(f) = finally { lower_stmts(ctx, f)?; diff --git a/crates/perry-codegen/src/volatile_setjmp.rs b/crates/perry-codegen/src/volatile_setjmp.rs deleted file mode 100644 index ad736e1044..0000000000 --- a/crates/perry-codegen/src/volatile_setjmp.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! `volatile` promotion for the allocas a `try` body mutates (#6385). -//! -//! Perry lowers `try`/`catch` to `setjmp`/`longjmp` (see `stmt/try_stmt.rs`), -//! not to LLVM unwind edges. `longjmp` restores the callee-saved registers and -//! the stack pointer that `setjmp` snapshotted — so any value LLVM decided to -//! keep in a register across the `setjmp` call reverts to its setjmp-time -//! contents when the exception fires. C spells the consequence out in -//! 7.13.2.1p3: an automatic object that is *modified between the `setjmp` and -//! the `longjmp`* and read afterwards has an indeterminate value unless it is -//! declared `volatile`. -//! -//! Perry's locals are alloca-backed, and at `-O2` `mem2reg`/`SROA` promote -//! those allocas to SSA registers. So the C hazard is exactly our hazard: -//! -//! ```text -//! let acc = 0; -//! try { acc = 41; throw e; } // store promoted into a callee-saved reg -//! catch { acc += 1; } // longjmp reverted the reg → acc reads 0 -//! ``` -//! -//! Historically Perry avoided this by stamping `optnone` on the **whole -//! function** containing the `try`. That is correct (at `-O0` every value is -//! spilled to the frame, and the frame survives `longjmp`) but it is a -//! sledgehammer: merely *having* a `try` — even one that never throws — cost a -//! 5x slowdown on the surrounding code, because the loop counters, the -//! arithmetic, the compares and the branches all stopped being optimized too. -//! -//! This module implements the `volatile` rule instead. `mem2reg` and `SROA` -//! both refuse to promote an alloca that has any volatile load/store -//! (`isAllocaPromotable` / SROA's slice analysis bail on `isVolatile()`), and -//! volatile accesses can be neither elided nor reordered against each other, so -//! the value provably lives in the frame across the `setjmp`. Everything else -//! in the function stays fully optimizable. -//! -//! # The volatile set — and why it is sound -//! -//! `LlBlock::emit` records the destination pointer of **every `store` -//! instruction emitted while codegen is inside a setjmp-protected region** -//! (`RegCounter::enter_try_region` / `exit_try_region`, driven from -//! `lower_try` and the async-boundary lowering). That recorded set is a -//! superset of "automatic objects this function modifies between a `setjmp` and -//! its `longjmp`", because: -//! -//! * The region is tracked by **emission depth**, not by block index, so it -//! automatically covers nested blocks, loops, nested `try`s, and the -//! duplicated finally bodies — anything lowered while the region is open, -//! regardless of which basic block the instruction lands in. -//! * We drop the "…and read after the longjmp" half of the C condition. Marking -//! an alloca that is written in the try but never read afterwards is merely -//! conservative, never wrong. -//! * Every access to a marked alloca is upgraded, function-wide — not just the -//! ones inside the try — because promotion is an all-or-nothing property of -//! the alloca. -//! * Pointers *derived* from an alloca (`getelementptr` into an -//! `alloca [N x double]`) are resolved back to their base alloca, so a store -//! through a derived pointer marks the whole object. -//! -//! Conversely, an alloca this function never stores to inside a try region -//! cannot have been modified between the `setjmp` and the `longjmp` **by this -//! frame**, and the only other way to modify it is for its address to escape to -//! a callee — which by itself already defeats `mem2reg`/`SROA` (an alloca with -//! a non-load/store user is not promotable), so those stay in memory anyway. -//! -//! Values that are *not* memory need no help: an SSA value defined inside the -//! try body cannot be read from the catch block (it does not dominate it), and -//! an SSA value defined *before* the `setjmp` and used after it is live across -//! the call, so the register allocator either spills it to the frame (which -//! `longjmp` preserves) or parks it in a callee-saved register (which `longjmp` -//! restores to the same, unmodified value). Module globals are memory, and -//! every try region is bracketed by opaque runtime calls (`js_try_push`, -//! `_setjmp`, `js_try_end`, `js_throw`) that LLVM must assume clobber them, so -//! they are never cached in a register across the boundary either. - -use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; -use std::sync::OnceLock; - -/// `PERRY_SETJMP_VOLATILE=0` / `off` / `false` turns the promotion OFF. -/// -/// **This produces miscompiled code and exists only to bisect/falsify.** With -/// the promotion disabled, a value written in a `try` body and read in the -/// `catch` silently reverts to its pre-`setjmp` value at `-O2`. It is here so -/// the guarantee this module provides is *testable*: build once, run -/// `test-files/test_gap_try_setjmp_volatile.ts` with and without the flag — -/// green with, red without. A test that passes both ways proves nothing. -/// -/// (Same spirit as `PERRY_WRITE_BARRIERS=0` and `PERRY_GEN_GC=0`: a -/// deliberately-unsound switch, for A/B only.) -fn promotion_enabled() -> bool { - static ENABLED: OnceLock = OnceLock::new(); - *ENABLED.get_or_init(|| match std::env::var("PERRY_SETJMP_VOLATILE") { - Ok(v) => !matches!(v.as_str(), "0" | "off" | "false"), - Err(_) => true, - }) -} - -/// Destination pointer operand of a `store` line. -/// -/// `store double %r4, ptr %r3, align 8` → `%r3`. Uses the LAST `, ptr ` -/// so that `store ptr %v, ptr %slot` (a pointer value stored into a slot) -/// still yields the destination rather than the value. -pub(crate) fn store_dest_ptr(line: &str) -> Option<&str> { - let t = line.trim_start(); - if !t.starts_with("store ") { - return None; - } - let at = t.rfind(", ptr ")?; - Some(first_operand(&t[at + ", ptr ".len()..])) -} - -/// Source pointer operand of a `load` line. -/// `%r5 = load double, ptr %r3, align 8` → `%r3`. -fn load_src_ptr(t: &str) -> Option<&str> { - let at = t.find(" = load ")?; - let rest = &t[at + " = load ".len()..]; - let p = rest.rfind(", ptr ")?; - Some(first_operand(&rest[p + ", ptr ".len()..])) -} - -/// `%r3 = alloca double` / `%r3 = alloca [8 x double], align 8` → `%r3`. -fn alloca_result(t: &str) -> Option<&str> { - let at = t.find(" = alloca ")?; - let res = &t[..at]; - res.starts_with('%').then_some(res) -} - -/// A pointer-producing instruction whose base is another pointer: -/// `%r9 = getelementptr inbounds [8 x double], ptr %r3, i64 0, i64 2` -/// → `(%r9, %r3)`. The base is the FIRST `, ptr ` operand. -fn derived_ptr(t: &str) -> Option<(&str, &str)> { - let at = t.find(" = getelementptr ")?; - let res = &t[..at]; - if !res.starts_with('%') { - return None; - } - let rest = &t[at + " = getelementptr ".len()..]; - let p = rest.find(", ptr ")?; - Some((res, first_operand(&rest[p + ", ptr ".len()..]))) -} - -/// First whitespace/comma-delimited token of `s`. -fn first_operand(s: &str) -> &str { - let end = s - .find(|c: char| c == ',' || c.is_whitespace()) - .unwrap_or(s.len()); - &s[..end] -} - -/// Rewrite `ir` so every load/store touching an alloca that the try region -/// stores into carries `volatile`. -/// -/// `try_stores` are the raw pointer operands recorded at emit time -/// (see [`store_dest_ptr`]); most are alloca registers, but the set may also -/// contain globals and heap pointers, which are filtered out here. -pub(crate) fn apply_setjmp_volatile(ir: &str, try_stores: &HashSet) -> String { - if !promotion_enabled() { - return ir.to_string(); - } - let lines: Vec<&str> = ir.lines().collect(); - - // 1. Every alloca defined in this function is its own base. - let mut base: HashMap<&str, &str> = HashMap::new(); - for l in &lines { - let t = l.trim_start(); - if let Some(r) = alloca_result(t) { - base.insert(r, r); - } - } - if base.is_empty() { - return ir.to_string(); - } - - // 2. Resolve derived pointers back to their base alloca. Blocks are - // rendered in creation order, which is not guaranteed to be a - // dominator order, so a single linear pass can miss a `getelementptr` - // whose base is defined further down the text. Iterate to a fixpoint. - loop { - let mut changed = false; - for l in &lines { - let t = l.trim_start(); - if let Some((res, src)) = derived_ptr(t) { - if !base.contains_key(res) { - if let Some(&b) = base.get(src) { - base.insert(res, b); - changed = true; - } - } - } - } - if !changed { - break; - } - } - - // 3. Allocas the try region writes — directly or through a derived pointer. - let mut volatile_allocas: HashSet<&str> = HashSet::new(); - for p in try_stores { - if let Some(&b) = base.get(p.as_str()) { - volatile_allocas.insert(b); - } - } - if volatile_allocas.is_empty() { - return ir.to_string(); - } - - // 4. Upgrade EVERY access to those allocas, function-wide — not just the - // ones inside the try. Suppressing promotion only takes one volatile - // access (mem2reg/SROA bail on any `isVolatile()` user), but the - // *individual* accesses must be volatile too: a plain load in the catch - // block could otherwise be forwarded by GVN from a plain store that - // dominates the setjmp, reintroducing the stale read we are fixing. - let mut out = String::with_capacity(ir.len() + 128); - for l in &lines { - out.push_str(&upgrade(l, &base, &volatile_allocas)); - out.push('\n'); - } - out -} - -fn is_volatile_target( - ptr: &str, - base: &HashMap<&str, &str>, - volatile_allocas: &HashSet<&str>, -) -> bool { - base.get(ptr).is_some_and(|b| volatile_allocas.contains(*b)) -} - -fn upgrade<'a>( - line: &'a str, - base: &HashMap<&str, &str>, - volatile_allocas: &HashSet<&str>, -) -> Cow<'a, str> { - let t = line.trim_start(); - let indent = &line[..line.len() - t.len()]; - - if t.starts_with("store ") { - if t.starts_with("store volatile ") { - return Cow::Borrowed(line); - } - if let Some(p) = store_dest_ptr(t) { - if is_volatile_target(p, base, volatile_allocas) { - return Cow::Owned(format!("{}store volatile {}", indent, &t["store ".len()..])); - } - } - return Cow::Borrowed(line); - } - - if let Some(at) = t.find(" = load ") { - let rest = &t[at + " = load ".len()..]; - if rest.starts_with("volatile ") { - return Cow::Borrowed(line); - } - if let Some(p) = load_src_ptr(t) { - if is_volatile_target(p, base, volatile_allocas) { - // `!invariant.load` promises the memory never changes — the - // exact opposite of what a try-mutated slot needs. Drop it. - let rest = rest.split(", !invariant.load").next().unwrap_or(rest); - return Cow::Owned(format!("{}{} = load volatile {}", indent, &t[..at], rest)); - } - } - } - - Cow::Borrowed(line) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn stores(regs: &[&str]) -> HashSet { - regs.iter().map(|s| s.to_string()).collect() - } - - #[test] - fn parses_store_destination_not_the_stored_pointer() { - assert_eq!(store_dest_ptr(" store double %r4, ptr %r3"), Some("%r3")); - assert_eq!( - store_dest_ptr(" store double %r4, ptr %r3, align 8"), - Some("%r3") - ); - // A pointer VALUE stored into a slot: the destination is the 2nd ptr. - assert_eq!(store_dest_ptr(" store ptr %r7, ptr %r2"), Some("%r2")); - assert_eq!(store_dest_ptr(" %r1 = load double, ptr %r2"), None); - } - - #[test] - fn upgrades_only_the_try_written_slot() { - let ir = "define double @f() #1 {\n\ - entry:\n\ - \x20 %acc = alloca double\n\ - \x20 %i = alloca double\n\ - \x20 store double 0.0, ptr %acc\n\ - \x20 store double 0.0, ptr %i\n\ - \x20 %v = load double, ptr %i\n\ - \x20 %a = load double, ptr %acc\n\ - \x20 ret double %a\n\ - }\n"; - let out = apply_setjmp_volatile(ir, &stores(&["%acc"])); - assert!(out.contains("store volatile double 0.0, ptr %acc")); - assert!(out.contains("%a = load volatile double, ptr %acc")); - // The loop counter is untouched — that is the whole point of #6385. - assert!(out.contains(" store double 0.0, ptr %i\n")); - assert!(out.contains(" %v = load double, ptr %i\n")); - } - - #[test] - fn a_store_through_a_gep_marks_the_whole_alloca() { - let ir = "define void @f() #1 {\n\ - entry:\n\ - \x20 %buf = alloca [4 x double]\n\ - \x20 %p0 = getelementptr inbounds [4 x double], ptr %buf, i64 0, i64 0\n\ - \x20 %p1 = getelementptr inbounds [4 x double], ptr %buf, i64 0, i64 1\n\ - \x20 store double 1.0, ptr %p1\n\ - \x20 %x = load double, ptr %p0\n\ - \x20 ret void\n\ - }\n"; - let out = apply_setjmp_volatile(ir, &stores(&["%p1"])); - assert!(out.contains("store volatile double 1.0, ptr %p1")); - // The sibling element of the same alloca is upgraded too: mem2reg/SROA - // promotability is a property of the alloca, not of one slice. - assert!(out.contains("%x = load volatile double, ptr %p0")); - } - - #[test] - fn globals_and_heap_pointers_are_not_allocas_and_stay_untouched() { - let ir = "define void @f() #1 {\n\ - entry:\n\ - \x20 %s = alloca double\n\ - \x20 store double 1.0, ptr @perry_global_m__3\n\ - \x20 %g = load double, ptr @perry_global_m__3\n\ - \x20 ret void\n\ - }\n"; - let out = apply_setjmp_volatile(ir, &stores(&["@perry_global_m__3"])); - assert!(!out.contains("volatile")); - } - - #[test] - fn already_volatile_accesses_are_left_alone() { - let ir = "define void @f() #1 {\n\ - entry:\n\ - \x20 %s = alloca double\n\ - \x20 store volatile double 1.0, ptr %s\n\ - \x20 %v = load volatile double, ptr %s\n\ - \x20 ret void\n\ - }\n"; - let out = apply_setjmp_volatile(ir, &stores(&["%s"])); - assert!(!out.contains("store volatile volatile")); - assert!(!out.contains("load volatile volatile")); - } - - #[test] - fn invariant_load_metadata_is_dropped_on_upgrade() { - let ir = "define void @f() #1 {\n\ - entry:\n\ - \x20 %s = alloca i64\n\ - \x20 store i64 1, ptr %s\n\ - \x20 %v = load i64, ptr %s, !invariant.load !0\n\ - \x20 ret void\n\ - }\n"; - let out = apply_setjmp_volatile(ir, &stores(&["%s"])); - assert!(out.contains("%v = load volatile i64, ptr %s")); - assert!(!out.contains("!invariant.load")); - } - - #[test] - fn no_try_stores_is_a_no_op() { - let ir = "define void @f() {\n\ - entry:\n\ - \x20 %s = alloca double\n\ - \x20 store double 1.0, ptr %s\n\ - \x20 ret void\n\ - }\n"; - let out = apply_setjmp_volatile(ir, &HashSet::new()); - assert_eq!(out, ir); - } -} diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index 40582c0bcc..45a477f917 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -1,8 +1,19 @@ -//! Exception handling runtime for Perry +//! Exception handling runtime for Perry. //! -//! Uses setjmp/longjmp for exception unwinding. -//! The key insight is that setjmp must be called directly from the generated code, -//! not from inside a Rust function (because the stack frame would be invalid when longjmp returns). +//! Two transports behind one handler stack (#7302): +//! +//! * **Generated `try`/`catch`** (`js_eh_try_push`, `HandlerKind::Unwind`): +//! `js_throw` raises through the system unwinder +//! (`_Unwind_RaiseException`; `RaiseException` on Windows) and the +//! frame's `landingpad`/`catchpad` receives control — see `crate::eh`. +//! * **Rust-side boundary traps** (`js_try_push` + `ffi::setjmp`, +//! `HandlerKind::Setjmp`): runtime helpers that drive user JS from a +//! Rust-owned context (`js_call_catching`, promise combinators, iterator +//! trampolines) catch via `longjmp` — Rust cannot catch a foreign +//! exception, and this is sound because an open Rust handler is always +//! innermost when it is the throw target (handler-stack order mirrors +//! stack order), so a raise never crosses one, and the frames a longjmp +//! discards are never resumed. // Platform-specific jmp_buf size (in i32 units) // macOS ARM64: _JBLEN = 48 (48 * 4 = 192 bytes) diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 53ec451680..2194ce4fbb 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -823,12 +823,6 @@ fn compute_object_cache_key_with_env( "env_target_cpu", env_var("PERRY_TARGET_CPU").as_deref().unwrap_or(""), ); - // #7302: PERRY_EH=invoke flips try/catch lowering from setjmp/longjmp to - // invoke/landingpad — structurally different IR for every try-containing - // function. Serving a cached object from the other mode would silently - // mix exception transports within one binary. (Temporary flag; deleted - // with the setjmp path when the default flips.) - h.field("env_eh", env_var("PERRY_EH").as_deref().unwrap_or("")); // Codegen tuning/emission toggles (#6394). Each is read by perry-codegen // at compile time and changes the emitted IR / .o bytes, so a warm cache // must not serve an object built under a different setting. These are From 666376fc7915fa874d65cd930432a0d6c21e6855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 13:27:06 +0200 Subject: [PATCH 11/13] docs(eh): changelog fragment + final status (#7302, PR #7305) --- changelog.d/7305-invoke-eh-lowering.md | 52 ++++++++++++++++++++++++++ docs/invoke-eh-experiment.md | 9 +++-- 2 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 changelog.d/7305-invoke-eh-lowering.md diff --git a/changelog.d/7305-invoke-eh-lowering.md b/changelog.d/7305-invoke-eh-lowering.md new file mode 100644 index 0000000000..af91933326 --- /dev/null +++ b/changelog.d/7305-invoke-eh-lowering.md @@ -0,0 +1,52 @@ +### Exception lowering: setjmp/longjmp → LLVM `invoke`/`landingpad` (#7302) + +`try`/`catch` (and the async rejection boundary) now lower to real LLVM +unwind edges instead of `setjmp`/`longjmp`. One root cause, three problems +collapsed: + +1. **Moving-GC soundness in `try` functions.** A `longjmp` could jump past a + `gc.statepoint`'s relocation write-back, which is why the statepoint + experiment (#7174) excluded `has_try` functions and fell back to an + unsound plain-stack-map lowering. With explicit unwind edges, relocations + exist on both the normal and unwind paths and no jump can skip one — the + GC branch can delete its `has_try` exclusion and the fallback entirely. +2. **~570 lines of register-allocator appeasement deleted.** + `volatile_setjmp.rs` (376) + `setjmp_abi.rs` (193) existed to implement + C99 7.13.2.1p3 (volatile promotion of try-mutated locals). Gone, along + with the `#0 returns_twice`/`#1 noinline` attribute groups. +3. **`try` functions now optimize.** No `returns_twice` barrier, no + `noinline`, no volatile-pinned locals: a function containing `try` can + inline and its locals live in SSA. + +Transport: `js_throw` stores the value in the GC-rooted TLS slot (unchanged), +applies the savepoint restores (unchanged), then raises a payload-free +`PERRYJS\0` `_Unwind_Exception` through a Perry-owned Itanium personality +(`perry_eh_personality`, an LSDA walk ported from Rust std). Landing pads are +catch-all and read the value back via `js_get_exception` — the catch-entry +sequence is bit-identical to the setjmp path. On windows-msvc the same +lowering emits SEH funclets (`catchswitch`/`catchpad`, personality +`__C_specific_handler`, filter on `RaiseException` code `0xE0504A53`). +Rust-side boundary traps (`js_call_catching`, combinators, iterator/timer +trampolines) keep their private `ffi::setjmp` — Rust cannot catch a foreign +exception, and an open Rust handler is always innermost when it is the throw +target, so a raise never crosses one. + +Build contract: the runtime archives are built `panic=abort` with +`-C force-unwind-tables=yes` — measured as the only configuration where the +unwinder steps runtime Rust helper frames with exactly `longjmp` semantics +(no cleanups, so the at-throw savepoint restores stay correct; under +`panic=unwind` the RFC-2945 abort guards on `extern "C"` helpers abort the +process instead). A once-per-process `_Unwind_Backtrace` self-check on the +first `try` aborts loudly if a stray `RUSTFLAGS` dropped the flag, instead of +stranding the first cross-helper throw. + +Tooling made invoke-aware: `scripts/gc_root_dominance_check.py` (CALL_RE + +invoke CFG edges — collecting calls inside `try` bodies stay visible), +`LlBlock::contains_gc_unsafe_call`, and a render-time phi-predecessor rewrite +for the inline `eh.contN` block splits. + +New gap coverage: structural path matrix +(`test_gap_7302_invoke_eh_paths.ts`), throws crossing runtime helper frames +(`test_gap_7302_throw_across_helper_frames.ts`), and the previously-missing +GC probe that allocates inside `try` and throws across a collection point +(`test_gap_7302_gc_throw_across_collection.ts`). diff --git a/docs/invoke-eh-experiment.md b/docs/invoke-eh-experiment.md index e6235d62da..b4c9dcece8 100644 --- a/docs/invoke-eh-experiment.md +++ b/docs/invoke-eh-experiment.md @@ -1,9 +1,10 @@ # Exception lowering: setjmp/longjmp → LLVM `invoke`/`landingpad` -Status: **in progress** — Phase 0 (spike) complete, Phase 1 (codegen) underway. -Branch: `exp/invoke-eh`. Development flag: `PERRY_EH=invoke|setjmp` (temporary — -deleted when the default flips; a permanent hybrid is the failure mode this -work exists to remove). +Status: **complete** — landed via PR #7305. The setjmp path and the temporary +`PERRY_EH` development flag are deleted; invoke/landingpad (SEH funclets on +windows-msvc) is Perry's only exception lowering. This document is the +campaign record: design decisions, the measurement matrices behind them, and +the acceptance evidence. ## Why From 5cc6084fdcc7ba223e2496a8d71ded33eeabad67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 13:42:11 +0200 Subject: [PATCH 12/13] =?UTF-8?q?docs(eh):=20final=20sweep=20results=20?= =?UTF-8?q?=E2=80=94=2095.4%,=20crash=20set=20collapsed=20to=20the=20one?= =?UTF-8?q?=20known=20entry=20(#7302)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/invoke-eh-experiment.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/invoke-eh-experiment.md b/docs/invoke-eh-experiment.md index b4c9dcece8..c2cc2b9d86 100644 --- a/docs/invoke-eh-experiment.md +++ b/docs/invoke-eh-experiment.md @@ -272,6 +272,15 @@ restored by the unwinder, so a snapshot restore would resurrect try-entry values for locals defined after the push, recreating exactly the volatile problem this migration deletes. +### Final sweep (merged branch, flipped default, coherent perry-dev build) + +95.4% parity, 21 output mismatches, **1 crash** (`test_gap_fetch_instanceof_5433`, +KNOWN, crashes identically under the setjmp build). The mismatch set is exactly +the attributed baseline — every entry previously proven mode-independent by +same-binary A/B. The 8 http-family crashes from the flag-period run are gone +with coherently-built archives, confirming the ext-archive profile-mixing +attribution. Zero invoke-attributable regressions, final. + ## Phase 1+ design notes (running) - Handler bookkeeping: `js_try_push` today returns a jmp_buf and the generated From 71d521987e8eb71e3fe6979b68dafbf07d1be05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 13:52:48 +0200 Subject: [PATCH 13/13] chore: bump version to 0.5.1280 --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e2638330dc..c33bd8f90f 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.1279 +**Current Version:** 0.5.1280 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 0b2bdd6537..88e5bdbb69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5541,7 +5541,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "base64", @@ -5601,14 +5601,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "cc", "libc", @@ -5616,7 +5616,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "inkwell", @@ -5632,7 +5632,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "perry-hir", @@ -5640,7 +5640,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "perry-hir", @@ -5648,7 +5648,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "perry-dispatch", @@ -5657,7 +5657,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "perry-hir", @@ -5665,7 +5665,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "base64", @@ -5677,7 +5677,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "perry-hir", @@ -5685,7 +5685,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "async-trait", @@ -5714,14 +5714,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "serde", "serde_json", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1279" +version = "0.5.1280" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5740,7 +5740,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "clap", @@ -5755,7 +5755,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "block2", "objc2", @@ -5765,7 +5765,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "argon2", "perry-ffi", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "reqwest", @@ -5782,7 +5782,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "bcrypt", "perry-ffi", @@ -5790,7 +5790,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "rusqlite", @@ -5798,7 +5798,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "scraper", @@ -5806,7 +5806,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "perry-runtime", @@ -5814,7 +5814,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "chrono", "cron", @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "chrono", "perry-ffi", @@ -5832,7 +5832,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "rust_decimal", @@ -5840,7 +5840,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "serde_json", @@ -5848,7 +5848,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5856,7 +5856,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "perry-runtime", @@ -5864,14 +5864,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "bytes", "http-body-util", @@ -5889,7 +5889,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "bytes", "lazy_static", @@ -5902,7 +5902,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "bytes", "h2", @@ -5926,7 +5926,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "lazy_static", "perry-ffi", @@ -5936,7 +5936,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "base64", "jsonwebtoken", @@ -5947,7 +5947,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "lru", "perry-ffi", @@ -5956,7 +5956,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "chrono", "perry-ffi", @@ -5964,7 +5964,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "bson", "futures-util", @@ -5976,7 +5976,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "chrono", "perry-ffi", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "nanoid", "perry-ffi", @@ -5995,7 +5995,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "bytes", "perry-ffi", @@ -6008,7 +6008,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6027,7 +6027,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "lettre", "perry-ffi", @@ -6037,7 +6037,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "printpdf", @@ -6045,7 +6045,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "sqlx", @@ -6054,7 +6054,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "governor", "perry-ffi", @@ -6062,7 +6062,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "fast_image_resize", "image", @@ -6072,14 +6072,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "lazy_static", "perry-ffi", @@ -6088,7 +6088,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "perry-runtime", @@ -6097,7 +6097,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "uuid", @@ -6105,7 +6105,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ffi", "regex", @@ -6115,7 +6115,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "futures-util", "lazy_static", @@ -6128,7 +6128,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "brotli", "flate2", @@ -6138,7 +6138,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "dashmap", "once_cell", @@ -6147,7 +6147,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "perry-api-manifest", @@ -6165,7 +6165,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "perry-diagnostics", @@ -6177,7 +6177,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "base64", @@ -6218,14 +6218,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6320,14 +6320,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "perry-hir", @@ -6336,14 +6336,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "base64", "itoa", @@ -6360,7 +6360,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "rand 0.10.1", "serde", @@ -6370,7 +6370,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6393,7 +6393,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "base64", "block2", @@ -6409,7 +6409,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "base64", "block2", @@ -6424,7 +6424,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1279" +version = "0.5.1280" [[package]] name = "perry-ui-test" @@ -6435,11 +6435,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1279" +version = "0.5.1280" [[package]] name = "perry-ui-tvos" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "base64", "block2", @@ -6455,7 +6455,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "base64", "block2", @@ -6471,7 +6471,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "block2", "libc", @@ -6484,7 +6484,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "base64", "libc", @@ -6501,14 +6501,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "anyhow", "base64", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1279" +version = "0.5.1280" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 4db895fd8a..981b2e4338 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -303,7 +303,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1279" +version = "0.5.1280" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"