From b90badb2f6499d845f145f3cef92aac99df60cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 20:24:30 +0200 Subject: [PATCH 1/6] runtime: route every longjmp-target setjmp through a C trampoline (#9305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rustc cannot express returns_twice, so a raw setjmp in a Rust frame is compiled under LLVM's one-return assumption — stack slots live only into the longjmp path get colored into unrelated normal-path temporaries. run_microtasks crashed exactly this way (cached TLS base spill reused by the task-record copy loop). No Rust frame is a longjmp target anymore: perry_sjlj_try (C, compiled with real setjmp semantics) is the only twice-returning frame, and Rust callers use exception::arm_trap_and_run / catch_js_throw. The one remaining raw setjmp (gc/roots.rs register snapshot) never longjmps and is documented as such. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- Cargo.lock | 1 + crates/perry-runtime/Cargo.toml | 4 + crates/perry-runtime/build.rs | 14 + crates/perry-runtime/src/array/iterator.rs | 74 +++-- crates/perry-runtime/src/collection_iter.rs | 13 +- crates/perry-runtime/src/dyn_eval/interp.rs | 71 +++-- crates/perry-runtime/src/dyn_eval/tests.rs | 14 +- crates/perry-runtime/src/exception.rs | 125 +++++++- crates/perry-runtime/src/ffi/perry_sjlj.c | 65 ++++ crates/perry-runtime/src/frame.rs | 24 +- crates/perry-runtime/src/fs/callbacks.rs | 14 +- .../src/fs/dir_glob_watch/watch.rs | 23 +- crates/perry-runtime/src/gc/roots.rs | 8 + crates/perry-runtime/src/native_abi.rs | 12 +- crates/perry-runtime/src/native_arena.rs | 12 +- crates/perry-runtime/src/native_handle.rs | 12 +- .../src/node_stream_constructors/builders.rs | 37 ++- .../perry-runtime/src/node_stream_pipeline.rs | 14 +- crates/perry-runtime/src/node_stream_tests.rs | 12 +- .../src/node_submodules/diagnostics.rs | 13 +- .../src/node_submodules/fs_promises.rs | 14 +- .../perry-runtime/src/node_submodules/mod.rs | 1 - .../src/node_submodules/stream_promises.rs | 14 +- .../perry-runtime/src/node_submodules/test.rs | 14 +- crates/perry-runtime/src/object/assert.rs | 31 +- crates/perry-runtime/src/object/tests.rs | 13 +- .../perry-runtime/src/promise/async_step.rs | 12 +- .../perry-runtime/src/promise/combinators.rs | 31 +- .../perry-runtime/src/promise/microtasks.rs | 277 +++++++++--------- crates/perry-runtime/src/promise/rejection.rs | 10 +- crates/perry-runtime/src/promise/then.rs | 76 ++--- crates/perry-runtime/src/timer.rs | 31 +- crates/perry-runtime/src/util_promisify.rs | 94 +++--- 33 files changed, 607 insertions(+), 573 deletions(-) create mode 100644 crates/perry-runtime/src/ffi/perry_sjlj.c diff --git a/Cargo.lock b/Cargo.lock index dd4ed8f82e..54250dedd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6314,6 +6314,7 @@ version = "0.5.1519" dependencies = [ "anyhow", "base64 0.22.1", + "cc", "dirs", "encoding_rs", "fancy-regex", diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index 74df755fbc..ec1f9b4340 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -426,6 +426,10 @@ mach2 = "0.6" # See `build.rs` and issue #395 for the rationale. [build-dependencies] perry-dispatch = { path = "../perry-dispatch" } +# Build-time only: compiles the 20-line setjmp trampoline +# (src/ffi/perry_sjlj.c) that the exception transport routes every +# jmp_buf arm through — rustc cannot express `returns_twice` (#9305). +cc = "1" # Build-time only: fingerprints the compiler/runtime source contract embedded # in libperry_runtime so the CLI can reject a stale archive before linking. sha2 = "0.11" diff --git a/crates/perry-runtime/build.rs b/crates/perry-runtime/build.rs index ed6ac73b11..acc9d81ad7 100644 --- a/crates/perry-runtime/build.rs +++ b/crates/perry-runtime/build.rs @@ -538,6 +538,20 @@ fn main() { println!("cargo:rerun-if-changed=src/node_api_host/symbols.txt"); println!("cargo:rerun-if-changed=../perry-dispatch/src/lib.rs"); println!("cargo:rerun-if-env-changed=TARGET"); + + // setjmp trampoline for the Rust-side exception transport (#9305). + // Must be compiled by a C compiler: rustc cannot express + // `returns_twice`, so a Rust frame containing a live `setjmp` is + // miscompiled under LLVM's one-return assumption (stack-slot + // coloring across the call). See the header comment in the C file. + println!("cargo:rerun-if-changed=src/ffi/perry_sjlj.c"); + cc::Build::new() + .file("src/ffi/perry_sjlj.c") + // The trampoline is never unwound through (a raise targeting a + // generated frame is always innermost-above it), but keep CFI so + // debuggers and the unwind-table self-check can walk past it. + .flag_if_supported("-fasynchronous-unwind-tables") + .compile("perry_sjlj"); println!( "cargo:rustc-env=PERRY_RUNTIME_TARGET={}", std::env::var("TARGET").expect("TARGET not set by Cargo") diff --git a/crates/perry-runtime/src/array/iterator.rs b/crates/perry-runtime/src/array/iterator.rs index ecc4571779..bf70ee469d 100644 --- a/crates/perry-runtime/src/array/iterator.rs +++ b/crates/perry-runtime/src/array/iterator.rs @@ -487,14 +487,15 @@ fn async_from_sync_call_raw(iter: f64, method: &[u8], args: &[f64]) -> Result Result Ok(Some(value)), + None => { + let exc = crate::exception::js_get_exception(); + crate::exception::js_clear_exception(); + Err(exc) + } }; if let Some(prev) = prev_this { crate::object::js_implicit_this_set(prev); @@ -545,20 +549,21 @@ fn async_from_sync_call_cached_raw( } let prev_this = crate::object::js_implicit_this_set(iter); let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut std::os::raw::c_int) }; - let result = if jumped == 0 { + let outcome = crate::exception::arm_trap_and_run(trap_buf, || { let args_ptr = if args.is_empty() { std::ptr::null() } else { args.as_ptr() }; - let value = - unsafe { crate::closure::js_native_call_value(method_value, args_ptr, args.len()) }; - Ok(Some(value)) - } else { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - Err(exc) + unsafe { crate::closure::js_native_call_value(method_value, args_ptr, args.len()) } + }); + let result = match outcome { + Some(value) => Ok(Some(value)), + None => { + let exc = crate::exception::js_get_exception(); + crate::exception::js_clear_exception(); + Err(exc) + } }; crate::object::js_implicit_this_set(prev_this); crate::exception::js_try_end(); @@ -1156,23 +1161,28 @@ pub(crate) fn array_from_spread_value(value: f64) -> *mut ArrayHeader { // `this` is the same defect one frame out. let prev_this_h = scope.root_nanbox_f64(prev_this); let trap_buf = crate::exception::js_try_push(); - let jumped = - unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut std::os::raw::c_int) }; // `js_try_push` captured the handle-stack depth AFTER these roots - // were pushed, so the `longjmp` restore below leaves them intact and - // reading them here is sound. - let iter = if jumped == 0 { + // were pushed, so the `longjmp` restore leaves them intact and + // reading them here is sound. Armed in a C trampoline frame + // (#9305); the rethrow below runs after `js_try_end` pops this + // trap, so it targets the enclosing handler. + let outcome = crate::exception::arm_trap_and_run(trap_buf, || { crate::closure::js_closure_call0(js_nanbox_get_pointer(rebound_h.get_nanbox_f64()) as *const crate::closure::ClosureHeader) - } else { - // Factory threw: restore the receiver and unwind the trap frame - // before re-propagating, so IMPLICIT_THIS can't leak into later - // calls (mirrors `async_from_sync_call_cached_raw` above). - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::object::js_implicit_this_set(prev_this_h.get_nanbox_f64()); - crate::exception::js_try_end(); - crate::exception::js_throw(exc) + }); + let iter = match outcome { + Some(iter) => iter, + None => { + // Factory threw: restore the receiver and unwind the trap + // frame before re-propagating, so IMPLICIT_THIS can't leak + // into later calls (mirrors `async_from_sync_call_cached_raw` + // above). + let exc = crate::exception::js_get_exception(); + crate::exception::js_clear_exception(); + crate::object::js_implicit_this_set(prev_this_h.get_nanbox_f64()); + crate::exception::js_try_end(); + crate::exception::js_throw(exc) + } }; let iter_h = scope.root_nanbox_f64(iter); crate::object::js_implicit_this_set(prev_this_h.get_nanbox_f64()); diff --git a/crates/perry-runtime/src/collection_iter.rs b/crates/perry-runtime/src/collection_iter.rs index 96712925d1..852ae7cea5 100644 --- a/crates/perry-runtime/src/collection_iter.rs +++ b/crates/perry-runtime/src/collection_iter.rs @@ -17,7 +17,6 @@ //! call into here so the throw-vs-empty-vs-consume decision lives in one place. use crate::value::{js_jsvalue_to_string, js_nanbox_get_pointer, JSValue, TAG_NULL, TAG_UNDEFINED}; -use std::os::raw::c_int; /// `typeof`-style word for a non-iterable value, used to build the Node /// " is not iterable" message. `null`/`undefined` are handled by the @@ -269,17 +268,7 @@ pub(crate) fn constructor_iter(value: f64) -> ConstructorIter { } pub(crate) fn call_capturing_throw(call: impl FnOnce() -> f64) -> Result { - let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut c_int) }; - let result = if jumped == 0 { - Ok(call()) - } else { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - Err(exc) - }; - crate::exception::js_try_end(); - result + crate::exception::catch_js_throw(call) } pub(crate) fn call_with_this_capturing_throw( diff --git a/crates/perry-runtime/src/dyn_eval/interp.rs b/crates/perry-runtime/src/dyn_eval/interp.rs index f896c3bc19..c7f60ea0d7 100644 --- a/crates/perry-runtime/src/dyn_eval/interp.rs +++ b/crates/perry-runtime/src/dyn_eval/interp.rs @@ -1012,40 +1012,38 @@ fn exec_switch(ctx: &Ctx, sw: &ast::SwitchStmt, env_idx: usize) -> Flow { /// runs on every exit path — including a throw out of the CATCH body, which /// a single-trap shape would miss. fn exec_try(ctx: &Ctx, t: &ast::TryStmt, env_idx: usize) -> Flow { - use crate::ffi::setjmp::setjmp; - let Some(finalizer) = &t.finalizer else { return exec_try_catch(ctx, t, env_idx); }; let trap = crate::exception::js_try_push(); - // SAFETY: this frame stays alive for the whole protected region; the cast - // matches libc's signature (see ffi::setjmp). - let jumped = unsafe { setjmp(trap as *mut std::os::raw::c_int) }; - if jumped == 0 { - let flow = exec_try_catch(ctx, t, env_idx); - crate::exception::js_try_end(); - match exec_block_scope(ctx, finalizer, env_idx) { + // Armed in a C trampoline frame (#9305). Both continuations below run + // after `js_try_end` pops this trap, so a throw out of the finalizer + // (or the rethrow) targets the enclosing handler — as before. + let outcome = crate::exception::arm_trap_and_run(trap, || exec_try_catch(ctx, t, env_idx)); + crate::exception::js_try_end(); + match outcome { + Some(flow) => match exec_block_scope(ctx, finalizer, env_idx) { Flow::Normal => flow, // An abrupt finalizer completion replaces the try/catch result. abrupt => abrupt, - } - } else { - // try (or catch) threw. Run the finalizer, then rethrow — unless the - // finalizer itself completes abruptly, which swallows the exception - // (spec Completion-record semantics). - crate::exception::js_try_end(); - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - let exc_idx = root_push(exc); - match exec_block_scope(ctx, finalizer, env_idx) { - Flow::Normal => { - let exc = root_get(exc_idx); - roots_truncate(exc_idx); - crate::exception::js_throw(exc) - } - abrupt => { - roots_truncate(exc_idx); - abrupt + }, + None => { + // try (or catch) threw. Run the finalizer, then rethrow — unless the + // finalizer itself completes abruptly, which swallows the exception + // (spec Completion-record semantics). + let exc = crate::exception::js_get_exception(); + crate::exception::js_clear_exception(); + let exc_idx = root_push(exc); + match exec_block_scope(ctx, finalizer, env_idx) { + Flow::Normal => { + let exc = root_get(exc_idx); + roots_truncate(exc_idx); + crate::exception::js_throw(exc) + } + abrupt => { + roots_truncate(exc_idx); + abrupt + } } } } @@ -1054,20 +1052,17 @@ fn exec_try(ctx: &Ctx, t: &ast::TryStmt, env_idx: usize) -> Flow { /// The try-block + catch-handler pair (no finalizer handling). #[inline(never)] fn exec_try_catch(ctx: &Ctx, t: &ast::TryStmt, env_idx: usize) -> Flow { - use crate::ffi::setjmp::setjmp; - let trap = crate::exception::js_try_push(); - // SAFETY: see exec_try. - let jumped = unsafe { setjmp(trap as *mut std::os::raw::c_int) }; - if jumped == 0 { - let flow = protected_block(ctx, t, env_idx); - crate::exception::js_try_end(); + // Armed in a C trampoline frame (#9305); the catch handler runs after + // `js_try_end`, so its own throws target the enclosing trap. + let outcome = crate::exception::arm_trap_and_run(trap, || protected_block(ctx, t, env_idx)); + crate::exception::js_try_end(); + if let Some(flow) = outcome { return flow; } - // A throw from the try block landed here. The pending exception is - // live; the interpreter savepoint has already restored the rooted stack - // + call depth to this try's entry state. - crate::exception::js_try_end(); + // A throw from the try block landed in the trampoline. The pending + // exception is live; the interpreter savepoint has already restored the + // rooted stack + call depth to this try's entry state. let exc = crate::exception::js_get_exception(); crate::exception::js_clear_exception(); diff --git a/crates/perry-runtime/src/dyn_eval/tests.rs b/crates/perry-runtime/src/dyn_eval/tests.rs index b39418c017..a74b5c18b4 100644 --- a/crates/perry-runtime/src/dyn_eval/tests.rs +++ b/crates/perry-runtime/src/dyn_eval/tests.rs @@ -47,19 +47,7 @@ fn truthy(v: f64) -> bool { /// Run `f` under a Rust-side landing pad; `Err(exception)` when it throws. /// Same setjmp idiom as the interpreter's own try/catch. fn catch_throw(f: impl FnOnce() -> f64) -> Result { - use crate::ffi::setjmp::setjmp; - let trap = crate::exception::js_try_push(); - let jumped = unsafe { setjmp(trap as *mut std::os::raw::c_int) }; - if jumped == 0 { - let v = f(); - crate::exception::js_try_end(); - Ok(v) - } else { - crate::exception::js_try_end(); - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - Err(exc) - } + crate::exception::catch_js_throw(f) } fn error_message(exc: f64) -> String { diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index 73bbcc4f12..a892505d7f 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -265,11 +265,117 @@ pub(crate) fn current_try_depth() -> usize { with_exception_state(|s| unsafe { (*s).try_depth }) } +// --------------------------------------------------------------------------- +// setjmp trampoline (#9305): no Rust frame is ever a longjmp target. +// --------------------------------------------------------------------------- + +extern "C" { + /// C-side setjmp trampoline (`src/ffi/perry_sjlj.c`, compiled by + /// build.rs). Arms `env` via the platform `setjmp` inside its own C + /// frame and invokes `body(ctx)` under it. Returns 0 when `body` + /// returns normally, or the `longjmp` value (always 1 — `js_throw`) + /// when a JS throw lands. + /// + /// WHY C: rustc has no `returns_twice`, so a Rust frame containing a + /// live `setjmp` is compiled under a one-return assumption — LLVM may + /// assign (color) a stack slot that is live only into the longjmp + /// return path to an unrelated temporary on the normal path. #9305 + /// was exactly that: `run_microtasks`' spilled TLS-base temporary was + /// overwritten by the task-record copy loop, and the longjmp path + /// reloaded NULL. Routing every arm through this trampoline makes the + /// hazard unrepresentable: Rust code only ever sees a single-return + /// call, and the one twice-returning frame is compiled by a C + /// compiler that knows setjmp's contract. + fn perry_sjlj_try( + env: *mut core::ffi::c_void, + body: unsafe extern "C" fn(*mut core::ffi::c_void), + ctx: *mut core::ffi::c_void, + ) -> core::ffi::c_int; +} + +/// Arm the jmp_buf `env` (from [`js_try_push`]) and run `f` under it. +/// +/// `Some(r)`: `f` completed normally. `None`: a `js_throw` longjmp landed +/// while `f` (or JS it called into) was running. The thrown value is left +/// in the TLS exception slot (`js_get_exception`), and the `try` frame is +/// still pushed — the caller owns `js_try_end`, exactly as with the raw +/// `setjmp` idiom this replaces. +/// +/// SAFETY CONTRACT for callers (same as the raw idiom, spelled out): +/// +/// * After a `None` return the jmp_buf points at a trampoline invocation +/// that has already returned. Until the caller either re-arms (calls +/// this again with the same `env`) or pops the frame (`js_try_end`), +/// nothing that can reach `js_throw` may run — a throw in that window +/// would longjmp into a dead frame. Handlers that can throw (they run +/// user JS: uncaught-exception listeners, promise rejection with hooks +/// active) must run *inside* a re-armed `f`, loop-style — see +/// `run_microtasks` / `with_timer_uncaught_trap`. +/// * A landing abandons the Rust frames between the throw point and the +/// trampoline without running destructors; `js_throw`'s savepoint +/// restores cover the runtime's own state (the runtime is panic=abort). +pub fn arm_trap_and_run R>(env: *mut i32, f: F) -> Option { + struct Ctx { + f: Option, + ret: Option, + } + unsafe extern "C" fn invoke R, R>(raw: *mut core::ffi::c_void) { + // SAFETY: `raw` is the `&mut Ctx` passed below, alive for the whole + // `perry_sjlj_try` call; the trampoline invokes us at most once. + let ctx = unsafe { &mut *(raw as *mut Ctx) }; + let f = ctx.f.take().expect("sjlj trampoline invoked body twice"); + ctx.ret = Some(f()); + } + let mut ctx: Ctx<_, R> = Ctx { f: Some(f), ret: None }; + // SAFETY: `env` points at a live 256-byte, 16-aligned JmpBuf slab owned + // by this thread's exception state; the trampoline's frame stays alive + // while `f` runs, so a longjmp from `js_throw` targets a live frame. + let rc = unsafe { + perry_sjlj_try( + env as *mut core::ffi::c_void, + invoke::, + &mut ctx as *mut Ctx<_, R> as *mut core::ffi::c_void, + ) + }; + if rc == 0 { + // `ret` empty with rc == 0 would mean the trampoline returned 0 + // without running the body to completion — a broken trampoline. + Some( + ctx.ret + .take() + .expect("sjlj trampoline returned 0 without a body result"), + ) + } else { + None + } +} + +/// Push a `try` frame, run `f` under it, pop it. `Ok(value)` on a normal +/// return; `Err(exception_bits)` — with the TLS exception slot cleared — if +/// `f` threw. The everything-in-one-call shape for handlers that are pure +/// Rust (read the exception, return it): between the landing and the pop +/// nothing that can throw runs, so the momentarily-stale jmp_buf is never +/// a live target. +pub fn catch_js_throw(f: impl FnOnce() -> R) -> Result { + let env = js_try_push(); + let outcome = arm_trap_and_run(env, f); + js_try_end(); + match outcome { + Some(r) => Ok(r), + None => { + let err = js_get_exception(); + js_clear_exception(); + Err(err) + } + } +} + /// Invoke `f` — which may call into user JS and `js_throw` — inside a `try` /// trap, catching any JS exception. Returns `Ok(value)` on a normal return, -/// or `Err(exception_bits)` if `f` threw. The `setjmp` lives in THIS frame, -/// which stays alive while `f` runs, so the `longjmp` target is valid, and the -/// throw unwinds only up to here — NOT past the Rust caller's frame. +/// or `Err(exception_bits)` if `f` threw. The armed jmp_buf lives in the C +/// trampoline's frame (see `arm_trap_and_run`), which stays alive while `f` +/// runs, so the `longjmp` target is valid, and the throw unwinds only up to +/// there — NOT past the Rust caller's frame. /// /// Runtime helpers that drive user JS from a Rust-owned microtask/timer /// context (e.g. a Web Streams `pull` callback) use this so a throwing @@ -277,18 +383,7 @@ pub(crate) fn current_try_depth() -> usize { /// their frames — skipping cleanup and corrupting state. Mirrors /// `combinators::combinator_catch_js`. pub fn js_call_catching(f: impl FnOnce() -> f64) -> Result { - let env = js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(env as *mut std::os::raw::c_int) }; - if jumped == 0 { - let result = f(); - js_try_end(); - Ok(result) - } else { - js_try_end(); - let err = js_get_exception(); - js_clear_exception(); - Err(err) - } + catch_js_throw(f) } /// Throw an exception with the given value diff --git a/crates/perry-runtime/src/ffi/perry_sjlj.c b/crates/perry-runtime/src/ffi/perry_sjlj.c new file mode 100644 index 0000000000..2c585c3ac0 --- /dev/null +++ b/crates/perry-runtime/src/ffi/perry_sjlj.c @@ -0,0 +1,65 @@ +/* setjmp trampoline for the Rust-side exception transport (issue #9305). + * + * WHY THIS FILE EXISTS — read before touching any trap site: + * + * Rust cannot mark an extern function `returns_twice` (the unstable + * `#[ffi_returns_twice]` attribute was removed from rustc), so a Rust + * function that calls `setjmp` directly is compiled under LLVM's + * one-return assumption. LLVM is then free to color stack slots across + * the call: a spill slot that is live only into the longjmp-return + * branch appears dead on the normal path, so an unrelated temporary can + * be assigned the same slot. That is exactly what crashed + * `run_microtasks` (#9305): the compiler's own cached TLS base pointer + * (`mov %fs:0x0,%rax`) was spilled before the `setjmp`, the microtask + * record-copy loop reused the slot on the normal path, and the longjmp + * return path reloaded 0 -> NULL-based TLS access -> SIGSEGV. The + * corrupted value was a compiler-generated temporary with no source + * name, so no Rust-side discipline (re-reading state from TLS after the + * jump, avoiding locals) can remove the hazard — it is a property of + * the caller's code generation. + * + * The durable fix: NO RUST FRAME IS EVER A longjmp TARGET. This C + * function is the only frame `setjmp` returns into twice, and it is + * compiled by a C compiler that knows setjmp's contract (clang/gcc + * recognize the name and apply `returns_twice`; MSVC lowers setjmp + * intrinsically). Its only state live across the call — `env`, `body`, + * `ctx` — is unmodified between `setjmp` and `longjmp`, which C + * guarantees to be preserved. Rust callers observe `perry_sjlj_try` as + * an ordinary single-return call, so every LLVM assumption about their + * frames holds. + * + * `env` is a jmp_buf slab from `exception.rs::js_try_push` (`JmpBuf`: + * 256 bytes, 16-byte aligned; see `ffi/setjmp.rs::JMP_BUF_MIN_BYTES` + * for the per-platform layout notes — on glibc the saved-signal-mask + * tail of `jmp_buf` is never written because glibc's `setjmp` does not + * save the mask). `js_throw` longjmps to it with value 1 while this + * frame is still live. + * + * Platform pairing matches the runtime's existing externs + * (`ffi/setjmp.rs`): Apple targets use the fast `_setjmp(3)` (no + * sigprocmask/sigaltstack round trip — measured at ~43% of microtask + * pump CPU when the signal-saving variant was used); everywhere else + * the plain `setjmp`. `js_throw`'s `longjmp` extern is unchanged, and + * on Windows it still zeroes `_JUMP_BUFFER.Frame` before jumping to + * force the non-unwinding POSIX-style longjmp (#7356). + */ + +#include + +typedef void (*perry_sjlj_body)(void *ctx); + +#if defined(__APPLE__) +/* Redeclaring with an explicit attribute is belt-and-braces: clang + * already treats `_setjmp` as returns_twice by name. */ +extern int _setjmp(jmp_buf) __attribute__((returns_twice)); +#define PERRY_SETJMP(env) _setjmp(env) +#else +#define PERRY_SETJMP(env) setjmp(env) +#endif + +int perry_sjlj_try(void *env, perry_sjlj_body body, void *ctx) { + int rc = PERRY_SETJMP(*(jmp_buf *)env); + if (rc == 0) + body(ctx); + return rc; +} diff --git a/crates/perry-runtime/src/frame.rs b/crates/perry-runtime/src/frame.rs index 5b61feeb50..485b647964 100644 --- a/crates/perry-runtime/src/frame.rs +++ b/crates/perry-runtime/src/frame.rs @@ -14,7 +14,6 @@ use crate::closure::ClosureHeader; use std::collections::HashMap; -use std::os::raw::c_int; use std::sync::Mutex; struct FrameCallback { @@ -53,13 +52,22 @@ fn next_frame_id() -> i64 { fn with_frame_uncaught_trap(f: F) { let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut c_int) }; - if jumped == 0 { - f(); - } else { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::os::emit_process_uncaught_exception(exc); + let mut f = Some(f); + // Armed in a C trampoline frame (#9305); loop shape so the uncaught + // path runs under a fresh arm — see `timer::with_timer_uncaught_trap`. + loop { + let completed = crate::exception::arm_trap_and_run(trap_buf, || { + if let Some(f) = f.take() { + f(); + } else { + let exc = crate::exception::js_get_exception(); + crate::exception::js_clear_exception(); + crate::os::emit_process_uncaught_exception(exc); + } + }); + if completed.is_some() { + break; + } } crate::exception::js_try_end(); } diff --git a/crates/perry-runtime/src/fs/callbacks.rs b/crates/perry-runtime/src/fs/callbacks.rs index 89de75c6c5..ec777525c2 100644 --- a/crates/perry-runtime/src/fs/callbacks.rs +++ b/crates/perry-runtime/src/fs/callbacks.rs @@ -1,7 +1,6 @@ //! Callback-style fs APIs — pre-flight probe + (err, value) dispatch. use crate::closure::ClosureHeader; -use std::os::raw::c_int; use super::*; @@ -116,18 +115,7 @@ fn callback_from_options_arg(options: f64, callback: f64) -> *const ClosureHeade } fn catch_callback_throw(call: impl FnOnce() -> f64) -> Result { - let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut c_int) }; - if jumped == 0 { - let value = call(); - crate::exception::js_try_end(); - Ok(value) - } else { - let err = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::exception::js_try_end(); - Err(err) - } + crate::exception::catch_js_throw(call) } /// Deliver an fs completion on a later event-loop turn under a real diff --git a/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs b/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs index 1848fc5308..4b392a3571 100644 --- a/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs +++ b/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs @@ -470,13 +470,22 @@ fn has_change_listeners(listeners: &HashMap>) -> bool fn with_watcher_uncaught_trap(f: F) { let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut std::os::raw::c_int) }; - if jumped == 0 { - f(); - } else { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::os::emit_process_uncaught_exception(exc); + let mut f = Some(f); + // Armed in a C trampoline frame (#9305); loop shape so the uncaught + // path runs under a fresh arm — see `timer::with_timer_uncaught_trap`. + loop { + let completed = crate::exception::arm_trap_and_run(trap_buf, || { + if let Some(f) = f.take() { + f(); + } else { + let exc = crate::exception::js_get_exception(); + crate::exception::js_clear_exception(); + crate::os::emit_process_uncaught_exception(exc); + } + }); + if completed.is_some() { + break; + } } crate::exception::js_try_end(); } diff --git a/crates/perry-runtime/src/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs index 63f745e1f0..08a8900989 100644 --- a/crates/perry-runtime/src/gc/roots.rs +++ b/crates/perry-runtime/src/gc/roots.rs @@ -445,6 +445,14 @@ pub(super) fn mark_stack_roots_unchecked( #[repr(C, align(16))] struct JmpBufWords([u64; 32]); let mut jmp_buf = JmpBufWords([0u64; 32]); // oversized for safety + // The ONE remaining raw `setjmp` call in Rust code, and the one place + // it is sound (#9305): this buffer is never a `longjmp` target — the + // call is a register-spilling trick (setjmp dumps the callee-saved + // registers into the buffer for the conservative scan below) and + // returns exactly once, so LLVM's single-return assumption holds. + // Every jmp_buf that CAN be longjmp'd to is armed through the C + // trampoline `exception::arm_trap_and_run` instead — never add a raw + // `setjmp` whose buffer reaches `js_throw`. unsafe { crate::ffi::setjmp::setjmp(jmp_buf.0.as_mut_ptr() as *mut std::os::raw::c_int); } diff --git a/crates/perry-runtime/src/native_abi.rs b/crates/perry-runtime/src/native_abi.rs index e57b2b6b2c..3b197028ed 100644 --- a/crates/perry-runtime/src/native_abi.rs +++ b/crates/perry-runtime/src/native_abi.rs @@ -478,17 +478,7 @@ mod tests { use std::os::raw::c_int; fn catch_runtime_throw(f: impl FnOnce()) -> bool { - let env = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(env as *mut c_int) }; - if jumped == 0 { - f(); - crate::exception::js_try_end(); - false - } else { - crate::exception::js_try_end(); - crate::exception::js_clear_exception(); - true - } + crate::exception::catch_js_throw(f).is_err() } fn boxed_ptr(ptr: *const T) -> f64 { diff --git a/crates/perry-runtime/src/native_arena.rs b/crates/perry-runtime/src/native_arena.rs index 5e8b74fa38..d0f12e585e 100644 --- a/crates/perry-runtime/src/native_arena.rs +++ b/crates/perry-runtime/src/native_arena.rs @@ -483,17 +483,7 @@ mod tests { } fn catch_runtime_throw(f: impl FnOnce()) -> bool { - let env = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(env as *mut c_int) }; - if jumped == 0 { - f(); - crate::exception::js_try_end(); - false - } else { - crate::exception::js_try_end(); - crate::exception::js_clear_exception(); - true - } + crate::exception::catch_js_throw(f).is_err() } unsafe fn dispatch_random_fill_sync(view: *mut T) -> f64 { diff --git a/crates/perry-runtime/src/native_handle.rs b/crates/perry-runtime/src/native_handle.rs index 53597b232f..1d0d31e887 100644 --- a/crates/perry-runtime/src/native_handle.rs +++ b/crates/perry-runtime/src/native_handle.rs @@ -464,17 +464,7 @@ mod tests { } fn catch_runtime_throw(f: impl FnOnce()) -> bool { - let env = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(env as *mut c_int) }; - if jumped == 0 { - f(); - crate::exception::js_try_end(); - false - } else { - crate::exception::js_try_end(); - crate::exception::js_clear_exception(); - true - } + crate::exception::catch_js_throw(f).is_err() } fn type_id(name: &str) -> i64 { diff --git a/crates/perry-runtime/src/node_stream_constructors/builders.rs b/crates/perry-runtime/src/node_stream_constructors/builders.rs index abfbef223e..bae862622e 100644 --- a/crates/perry-runtime/src/node_stream_constructors/builders.rs +++ b/crates/perry-runtime/src/node_stream_constructors/builders.rs @@ -5,7 +5,6 @@ use super::*; use crate::closure::ClosureHeader; use crate::object::{js_object_get_field_by_name_f64, js_object_set_field_by_name, ObjectHeader}; use crate::value::JSValue; -use std::os::raw::c_int; #[no_mangle] pub extern "C" fn js_node_stream_readable_new(opts: f64) -> f64 { @@ -552,29 +551,27 @@ pub extern "C" fn js_node_stream_readable_from_options(iterable: f64, opts: f64) let readable = js_node_stream_readable_new(readable_from_options(opts)); let raw = raw_ptr_from_value(readable); if raw >= 0x10000 { - let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut c_int) }; - if jumped == 0 { - let normalized = normalize_readable_from_input(iterable); - crate::exception::js_try_end(); - js_object_set_field_by_name( - raw as *mut ObjectHeader, - hidden_chunks_key(), - normalized.chunks, - ); - initialize_readable_from_buffered_length(readable, normalized.chunks); - if let Some(source_iterator) = normalized.source_iterator { + // Armed in a C trampoline frame (#9305); both continuations run + // after the trap is popped, as before. + match crate::exception::catch_js_throw(|| normalize_readable_from_input(iterable)) { + Ok(normalized) => { js_object_set_field_by_name( raw as *mut ObjectHeader, - hidden_key(READABLE_SOURCE_ITERATOR_KEY), - source_iterator, + hidden_chunks_key(), + normalized.chunks, ); + initialize_readable_from_buffered_length(readable, normalized.chunks); + if let Some(source_iterator) = normalized.source_iterator { + js_object_set_field_by_name( + raw as *mut ObjectHeader, + hidden_key(READABLE_SOURCE_ITERATOR_KEY), + source_iterator, + ); + } + } + Err(err) => { + destroy_stream(readable, err); } - } else { - let err = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::exception::js_try_end(); - destroy_stream(readable, err); } } readable diff --git a/crates/perry-runtime/src/node_stream_pipeline.rs b/crates/perry-runtime/src/node_stream_pipeline.rs index 1b5116388a..23c8097108 100644 --- a/crates/perry-runtime/src/node_stream_pipeline.rs +++ b/crates/perry-runtime/src/node_stream_pipeline.rs @@ -8,7 +8,6 @@ use crate::closure::{ use crate::object::{ js_object_alloc, js_object_get_field_by_name_f64, js_object_set_field_by_name, ObjectHeader, }; -use std::os::raw::c_int; #[derive(Clone, Copy)] pub(super) struct PipelineOptions { @@ -375,18 +374,7 @@ pub(super) fn settle_pipeline_value(value: f64) -> Result { } pub(super) fn catch_pipeline_throw(call: impl FnOnce() -> f64) -> Result { - let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut c_int) }; - if jumped == 0 { - let value = call(); - crate::exception::js_try_end(); - Ok(value) - } else { - let err = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::exception::js_try_end(); - Err(err) - } + crate::exception::catch_js_throw(call) } pub(super) fn collect_pipeline_chunks(value: f64) -> Result { diff --git a/crates/perry-runtime/src/node_stream_tests.rs b/crates/perry-runtime/src/node_stream_tests.rs index 4c264bfb88..06c756bbad 100644 --- a/crates/perry-runtime/src/node_stream_tests.rs +++ b/crates/perry-runtime/src/node_stream_tests.rs @@ -32,17 +32,7 @@ thread_local! { } fn catches_runtime_throw(f: impl FnOnce()) -> bool { - let env = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(env as *mut c_int) }; - if jumped == 0 { - f(); - crate::exception::js_try_end(); - false - } else { - crate::exception::js_try_end(); - crate::exception::js_clear_exception(); - true - } + crate::exception::catch_js_throw(f).is_err() } pub(super) fn string_value(s: &str) -> f64 { diff --git a/crates/perry-runtime/src/node_submodules/diagnostics.rs b/crates/perry-runtime/src/node_submodules/diagnostics.rs index 08d308f29f..a0268b8124 100644 --- a/crates/perry-runtime/src/node_submodules/diagnostics.rs +++ b/crates/perry-runtime/src/node_submodules/diagnostics.rs @@ -804,18 +804,7 @@ pub(crate) fn method_id(closure: *const ClosureHeader) -> i64 { } pub(crate) fn catch_js f64>(f: F) -> Result { - let env = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(env as *mut c_int) }; - if jumped == 0 { - let result = f(); - crate::exception::js_try_end(); - Ok(result) - } else { - crate::exception::js_try_end(); - let err = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - Err(err) - } + crate::exception::catch_js_throw(f) } // #854: diagnostics_channel captured-error helper retained for the subsystem diff --git a/crates/perry-runtime/src/node_submodules/fs_promises.rs b/crates/perry-runtime/src/node_submodules/fs_promises.rs index 5b1a2251a7..20df7d3674 100644 --- a/crates/perry-runtime/src/node_submodules/fs_promises.rs +++ b/crates/perry-runtime/src/node_submodules/fs_promises.rs @@ -17,7 +17,6 @@ use crate::object::{ use crate::string::{js_string_from_bytes, StringHeader}; use crate::url::abort::abort_signal_ptr_from_value; use crate::value::{js_jsvalue_to_string, JSValue}; -use std::os::raw::c_int; pub(crate) fn promise_value(value: f64) -> f64 { let promise = crate::promise::js_promise_new(); @@ -35,18 +34,7 @@ pub(crate) fn promise_undefined() -> f64 { } fn catch_fs_promises_throw(call: impl FnOnce() -> f64) -> Result { - let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut c_int) }; - if jumped == 0 { - let value = call(); - crate::exception::js_try_end(); - Ok(value) - } else { - let err = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::exception::js_try_end(); - Err(err) - } + crate::exception::catch_js_throw(call) } fn promise_from_sync_value(call: impl FnOnce() -> f64) -> f64 { diff --git a/crates/perry-runtime/src/node_submodules/mod.rs b/crates/perry-runtime/src/node_submodules/mod.rs index 724988489b..e58f3f120c 100644 --- a/crates/perry-runtime/src/node_submodules/mod.rs +++ b/crates/perry-runtime/src/node_submodules/mod.rs @@ -21,7 +21,6 @@ //! its helpers while the broader #793 Node compatibility roadmap continues. use std::cell::RefCell; -use std::os::raw::c_int; use std::sync::atomic::{AtomicI64, Ordering}; use crate::closure::{ diff --git a/crates/perry-runtime/src/node_submodules/stream_promises.rs b/crates/perry-runtime/src/node_submodules/stream_promises.rs index 1de33a7384..ef1e96ff47 100644 --- a/crates/perry-runtime/src/node_submodules/stream_promises.rs +++ b/crates/perry-runtime/src/node_submodules/stream_promises.rs @@ -20,7 +20,6 @@ use crate::object::{ }; use crate::string::js_string_from_bytes; use crate::value::JSValue; -use std::os::raw::c_int; #[inline] pub(crate) fn undefined_value() -> f64 { @@ -556,18 +555,7 @@ extern "C" fn stream_promises_pipeline_callback( } fn catch_stream_promises_throw(call: impl FnOnce()) -> Result<(), f64> { - let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut c_int) }; - if jumped == 0 { - call(); - crate::exception::js_try_end(); - Ok(()) - } else { - let err = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::exception::js_try_end(); - Err(err) - } + crate::exception::catch_js_throw(call) } #[allow(non_snake_case)] // thunk name mirrors JS API surface diff --git a/crates/perry-runtime/src/node_submodules/test.rs b/crates/perry-runtime/src/node_submodules/test.rs index 28b17a1a1f..956434e867 100644 --- a/crates/perry-runtime/src/node_submodules/test.rs +++ b/crates/perry-runtime/src/node_submodules/test.rs @@ -6,7 +6,6 @@ use std::cell::{Cell, RefCell}; use std::fs; -use std::os::raw::c_int; use crate::closure::{ js_closure_alloc, js_closure_call0, js_closure_call1, js_closure_get_capture_f64, @@ -183,18 +182,7 @@ fn object_string(value: f64, name: &[u8]) -> Option { } fn catch_js f64>(f: F) -> Result { - let env = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(env as *mut c_int) }; - if jumped == 0 { - let result = f(); - crate::exception::js_try_end(); - Ok(result) - } else { - crate::exception::js_try_end(); - let err = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - Err(err) - } + crate::exception::catch_js_throw(f) } fn throw_error_with_code(message: &str, code: &'static str) -> ! { diff --git a/crates/perry-runtime/src/object/assert.rs b/crates/perry-runtime/src/object/assert.rs index f7dc77a7bf..1fde9c779d 100644 --- a/crates/perry-runtime/src/object/assert.rs +++ b/crates/perry-runtime/src/object/assert.rs @@ -3,7 +3,6 @@ //! Split out of `object/mod.rs` (issue #1103). Pure relocation — no //! logic changes. -use std::os::raw::c_int; use super::*; @@ -322,34 +321,16 @@ fn expected_is_callable(value: f64) -> bool { /// validator can't escape as an uncaught exception). Mirrors /// [`call_block_capturing_throw`] but passes one argument. fn call_validator_capturing(validator: f64, arg: f64) -> Result { - let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut c_int) }; - let result = if jumped == 0 { + crate::exception::catch_js_throw(|| { let args = [arg]; - let value = unsafe { crate::closure::js_native_call_value(validator, args.as_ptr(), 1) }; - Ok(value) - } else { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - Err(exc) - }; - crate::exception::js_try_end(); - result + unsafe { crate::closure::js_native_call_value(validator, args.as_ptr(), 1) } + }) } fn call_block_capturing_throw(block: f64) -> Result { - let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut c_int) }; - let result = if jumped == 0 { - let value = unsafe { crate::closure::js_native_call_value(block, std::ptr::null(), 0) }; - Ok(value) - } else { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - Err(exc) - }; - crate::exception::js_try_end(); - result + crate::exception::catch_js_throw(|| unsafe { + crate::closure::js_native_call_value(block, std::ptr::null(), 0) + }) } fn invalid_function_argument(arg_name: &str, value: f64) -> ! { diff --git a/crates/perry-runtime/src/object/tests.rs b/crates/perry-runtime/src/object/tests.rs index 5dcfb7c0c9..6a69116762 100644 --- a/crates/perry-runtime/src/object/tests.rs +++ b/crates/perry-runtime/src/object/tests.rs @@ -61,18 +61,7 @@ fn js_string_to_rust(value: JSValue) -> String { } fn catch_js f64>(f: F) -> Result { - let env = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(env as *mut c_int) }; - if jumped == 0 { - let result = f(); - crate::exception::js_try_end(); - Ok(result) - } else { - crate::exception::js_try_end(); - let err = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - Err(err) - } + crate::exception::catch_js_throw(f) } unsafe fn installed_builtin_method(ctor_name: &str, method_name: &str) -> f64 { diff --git a/crates/perry-runtime/src/promise/async_step.rs b/crates/perry-runtime/src/promise/async_step.rs index 08af32de52..3b38954990 100644 --- a/crates/perry-runtime/src/promise/async_step.rs +++ b/crates/perry-runtime/src/promise/async_step.rs @@ -250,17 +250,7 @@ pub extern "C" fn js_promise_resolved(value: f64) -> *mut Promise { /// iterator/generator algorithms turn an abrupt constructor getter into a /// rejected result promise instead of throwing synchronously to their caller. pub fn js_promise_resolved_catching(value: f64) -> Result<*mut Promise, f64> { - let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut std::os::raw::c_int) }; - let result = if jumped == 0 { - Ok(js_promise_resolved(value)) - } else { - let reason = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - Err(reason) - }; - crate::exception::js_try_end(); - result + crate::exception::catch_js_throw(|| js_promise_resolved(value)) } /// Fused fast path for `Promise.resolve(value).then(cb_f, cb_e)` — diff --git a/crates/perry-runtime/src/promise/combinators.rs b/crates/perry-runtime/src/promise/combinators.rs index 0c5dde2a30..eb0a4afb6e 100644 --- a/crates/perry-runtime/src/promise/combinators.rs +++ b/crates/perry-runtime/src/promise/combinators.rs @@ -3,7 +3,6 @@ //! assimilation, the scheduled-resolve queue, and `is_promise` probes. use super::*; -use std::os::raw::c_int; use super::keyed_table::PromiseKeyedTable; @@ -206,20 +205,9 @@ fn promise_try_call(callback: f64, args_ptr: *const f64, args_len: usize) -> Res return Err(promise_try_type_error_value(callback)); }; - let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut c_int) }; - let result = if jumped == 0 { - let value = unsafe { - crate::closure::js_closure_call_array(closure as i64, args_ptr, args_len as i64) - }; - Ok(value) - } else { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - Err(exc) - }; - crate::exception::js_try_end(); - result + crate::exception::catch_js_throw(|| unsafe { + crate::closure::js_closure_call_array(closure as i64, args_ptr, args_len as i64) + }) } /// `Promise.try(fn, ...args)`: call `fn` with forwarded args and normalize the @@ -350,18 +338,7 @@ fn not_iterable_prefix(value: f64) -> String { } pub(super) fn combinator_catch_js f64>(f: F) -> Result { - let env = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(env as *mut c_int) }; - if jumped == 0 { - let result = f(); - crate::exception::js_try_end(); - Ok(result) - } else { - crate::exception::js_try_end(); - let err = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - Err(err) - } + crate::exception::catch_js_throw(f) } /// Build the `TypeError: is not iterable (cannot read property diff --git a/crates/perry-runtime/src/promise/microtasks.rs b/crates/perry-runtime/src/promise/microtasks.rs index 8f8b78bf67..cb3adcc735 100644 --- a/crates/perry-runtime/src/promise/microtasks.rs +++ b/crates/perry-runtime/src/promise/microtasks.rs @@ -265,87 +265,139 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { // thread-local set just before invoking the callback, reject its // `next`, and continue the loop. // - // ── macOS/BSD: use `_setjmp` (no signal-mask save) ──────────── + // ── macOS/BSD: the arm uses `_setjmp` (no signal-mask save) ──── // On Apple platforms the C `setjmp(3)` saves the signal mask via a // `sigprocmask` system call AND saves the alt-signal-stack via - // `__sigaltstack`. Profiling `promise_all_chains` showed those two - // syscalls accounted for ~43% of CPU time even though `setjmp` is - // called once per `run_microtasks` drain — each kernel-mode round - // trip is ~25 μs because macOS arm64 uses BSD-style "save signal - // state for siglongjmp" semantics. Perry never `siglongjmp`s out - // of a signal handler — `js_throw` runs in normal user context, so - // the signal mask doesn't need to be saved/restored on - // setjmp/longjmp pairs. POSIX's `_setjmp` / `_longjmp` are exactly - // that: setjmp/longjmp without the sigprocmask round-trip. - // - // On Linux glibc the C `setjmp` already doesn't save the signal - // mask (POSIX leaves it implementation-defined; glibc opted for - // the fast path), so the `setjmp` extern there is fine. Other - // BSDs (FreeBSD, NetBSD, OpenBSD) match macOS — they too benefit - // from `_setjmp`. We gate on `target_vendor = "apple"` for now - // since that's where we've measured the win. - // `setjmp` lives in `crate::ffi::setjmp` — one canonical extern - // declaration shared with `gc.rs` (issue #856). The libc-matching - // signature is `unsafe extern "C" fn(*mut c_int) -> c_int`; on - // Apple it links to the fast `_setjmp(3)` variant, on glibc Linux - // to plain `setjmp(3)` which already skips the signal-mask save. - use crate::ffi::setjmp::setjmp; + // `__sigaltstack` — measured at ~43% of `promise_all_chains` CPU. + // Perry never `siglongjmp`s out of a signal handler, so the fast + // `_setjmp(3)` is used instead. That per-platform choice now lives in + // the C trampoline (`src/ffi/perry_sjlj.c`); on glibc Linux the plain + // `setjmp(3)` already skips the signal-mask save. + // #9305: the jmp_buf arm must live in a C frame — see + // `exception::arm_trap_and_run`. rustc cannot express `returns_twice`, + // so with a raw `setjmp` in this Rust frame LLVM colored the stack slot + // of the spilled TLS-base temporary into the task-record copy loop on + // the normal path, and the longjmp return path reloaded NULL. One + // `js_try_push` for the whole drain, one re-arm per caught throw; the + // recovery itself runs inside the NEXT protected invocation, so a throw + // out of the rejection plumbing (promise hooks can run JS) still lands + // in a live trampoline frame, exactly like the old always-armed setjmp. let trap_buf = crate::exception::js_try_push(); - // SAFETY: The setjmp call must remain in this stack frame; we - // longjmp to it from `js_throw` only while this frame is still - // alive (inside the loop below). The cast `*mut i32 -> *mut c_int` - // is a no-op on every Perry-supported target (c_int is i32 - // everywhere), but it spells the intent at the FFI boundary so - // the shared declaration in `ffi::setjmp` stays the single source - // of truth for libc's signature. - let jumped = unsafe { setjmp(trap_buf as *mut std::os::raw::c_int) }; - if jumped != 0 { - restore_all_microtask_contexts(); - crate::builtins::restore_queued_microtask_contexts(); - // A microtask's callback threw and unwound here. Read the - // exception, clear it, and reject the `next` promise of the - // microtask that was running. js_try_end is intentionally NOT - // called yet — we want the trap to remain in scope for the - // rest of the loop. - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - let cur = CURRENT_MICROTASK_PROMISE.with(|c| c.replace(std::ptr::null_mut())); - CURRENT_MICROTASK_CALLBACK.with(|c| c.set(std::ptr::null())); - CURRENT_MICROTASK_VALUE.with(|c| c.set(0.0)); - CURRENT_MICROTASK_NEXT.with(|c| c.set(std::ptr::null_mut())); - let unwound_trap = INLINE_TRAP.with(|c| c.replace(InlineTrap::empty())); - // `longjmp` bypasses Rust destructors and normal dispatch tails. Drain - // exactly the activation references acquired since THIS (possibly - // re-entrant) runner began; an enclosing activation is below the saved - // depth and must remain owned when this runner returns. - // Re-read the boundary from TLS after the non-local jump. A Rust local - // captured before `setjmp` is not stable here in optimized builds: its - // storage can be reused on the ordinary path before `longjmp` resumes - // this branch (#8937). - let async_box_ref_depth = ASYNC_BOX_EXECUTION_REF_BASES.with(|bases| { - *bases - .borrow() - .last() - .expect("microtask execution-ref boundary") - }) as usize; - unwind_async_box_execution_refs(async_box_ref_depth); - if !cur.is_null() { - unsafe { - if !(*cur).next.is_null() { - js_promise_reject((*cur).next, exc); + let mut landed = false; + loop { + let completed = crate::exception::arm_trap_and_run(trap_buf, || { + pump_protected(mode, reentrant, landed, &mut ran) + }); + if completed.is_some() { + break; + } + // A JS throw longjmp-landed in the trampoline. The jmp_buf is stale + // until `arm_trap_and_run` re-arms it above, and nothing between + // here and there can throw (this assignment is all there is). + // `landed` stays true for the rest of the drain: every re-entry + // recovers before draining further. + landed = true; + } + crate::exception::js_try_end(); + crate::node_submodules::diagnostics_channel_drain_uncaught(); + + let _ = crate::gc::gc_runtime_safepoint(); + + // Phase 1 of the moving-GC project (see project_gc_one_great_moving_gc): at + // the OUTERMOST microtask-pump boundary the JS stack has fully unwound, so + // there are no live register temporaries and the copying (moving) minor runs + // with precise, rewritable roots — no forced conservative scan. Run it when + // nursery pressure is due so programs that yield to the event loop get + // compacting, O(survivors) young collection instead of the non-moving + // alloc-point fallback. Gated (default off); additive. + if crate::gc::gc_moving_safepoint_enabled() + && MICROTASK_RUN_DEPTH.with(|depth| depth.get().pump) == 1 + { + crate::gc::gc_safepoint_moving_minor(); + } + + // Fallback for release entry points invoked without a tracked plain-async + // activation (principally direct runtime tests). Production async frames + // publish at their own queued/running AsyncStep refcount reaching zero; + // they do not wait for this global pump boundary. + if MICROTASK_RUN_DEPTH.with(|depth| depth.get().pump) == 1 + && TASK_QUEUE.with(|q| q.borrow().is_empty()) + { + crate::r#box::flush_released_boxes(); + } + + ASYNC_BOX_EXECUTION_REF_BASES.with(|bases| { + let base = bases + .borrow_mut() + .pop() + .expect("microtask execution-ref boundary"); + debug_assert_eq!(async_box_execution_ref_depth(), base as usize); + }); + + MICROTASK_RUN_DEPTH.with(|depth| { + let mut current = depth.get(); + current.pump = current.pump.saturating_sub(1); + depth.set(current); + }); + + ran +} + +/// The microtask trap's protected region (#9305): the recovery for a +/// just-landed throw (`landed`), the tick/task drain loop, the +/// jobs-quiescent decrement, rejection processing, and the timer phases. +/// Runs ONLY under an armed trampoline (`exception::arm_trap_and_run`): +/// a JS throw that reaches the runner's trap longjmps out of this +/// function into the trampoline, abandoning this frame — state that must +/// survive a landing lives behind `ran`'s reference or in TLS, never in +/// a local. +fn pump_protected(mode: MicrotaskDrainMode, reentrant: bool, landed: bool, ran: &mut i32) { + if landed { + restore_all_microtask_contexts(); + crate::builtins::restore_queued_microtask_contexts(); + // A microtask's callback threw and unwound here. Read the + // exception, clear it, and reject the `next` promise of the + // microtask that was running. The try frame stays pushed — the + // caller re-arms it for the rest of the drain (js_try_end runs + // after the pump loop completes). + let exc = crate::exception::js_get_exception(); + crate::exception::js_clear_exception(); + let cur = CURRENT_MICROTASK_PROMISE.with(|c| c.replace(std::ptr::null_mut())); + CURRENT_MICROTASK_CALLBACK.with(|c| c.set(std::ptr::null())); + CURRENT_MICROTASK_VALUE.with(|c| c.set(0.0)); + CURRENT_MICROTASK_NEXT.with(|c| c.set(std::ptr::null_mut())); + let unwound_trap = INLINE_TRAP.with(|c| c.replace(InlineTrap::empty())); + // `longjmp` bypasses Rust destructors and normal dispatch tails. Drain + // exactly the activation references acquired since THIS (possibly + // re-entrant) runner began; an enclosing activation is below the saved + // depth and must remain owned when this runner returns. + // Re-read the boundary from TLS after the non-local jump. A Rust local + // held across a landing is not stable (#8937) — this function's frame + // was abandoned by the longjmp; only TLS and memory behind `ran` are. + let async_box_ref_depth = ASYNC_BOX_EXECUTION_REF_BASES.with(|bases| { + *bases + .borrow() + .last() + .expect("microtask execution-ref boundary") + }) as usize; + unwind_async_box_execution_refs(async_box_ref_depth); + if !cur.is_null() { + unsafe { + if !(*cur).next.is_null() { + js_promise_reject((*cur).next, exc); + } } - } - ran += 1; - } else { - if !unwound_trap.trap_next.is_null() { - js_promise_reject(unwound_trap.trap_next, exc); - ran += 1; + *ran += 1; } else { - crate::node_submodules::diagnostics::schedule_uncaught(exc); - ran += 1; + if !unwound_trap.trap_next.is_null() { + js_promise_reject(unwound_trap.trap_next, exc); + *ran += 1; + } else { + crate::node_submodules::diagnostics::schedule_uncaught(exc); + *ran += 1; + } } - } } // Cached profile flag — set once by mt_profile_register() above. @@ -381,12 +433,12 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { false }; loop { - let ran_before_checkpoint = ran; + let ran_before_checkpoint = *ran; // Node runs process.nextTick jobs before regular microtasks, while // queueMicrotask jobs share FIFO order with Promise reactions. if ticks_allowed && !esm_defer_tick_drain { - ran += crate::builtins::drain_queued_microtasks_count(); + *ran += crate::builtins::drain_queued_microtasks_count(); } loop { @@ -453,7 +505,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { CURRENT_MICROTASK_NEXT.with(|c| c.set(std::ptr::null_mut())); clear_promise_context(promise); restore_microtask_context(); - ran += 1; + *ran += 1; continue; } @@ -579,7 +631,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { }); } restore_microtask_context(); - ran += 1; + *ran += 1; } Some(Task::PromiseAll(mut state, value, is_fulfilled, task_context)) => { bump(&MT_RUN_COUNT); @@ -604,7 +656,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { let value = value_h.get_nanbox_f64(); combinators::promise_all_settle(state, value, is_fulfilled); restore_microtask_context(); - ran += 1; + *ran += 1; } Some(Task::Inline(callback, value, next, is_fulfilled, task_context)) => { bump(&MT_RUN_COUNT); @@ -646,7 +698,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { } crate::async_hooks::after_promise(async_id); restore_microtask_context(); - ran += 1; + *ran += 1; continue; } @@ -725,7 +777,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { .fetch_add(t.elapsed().as_nanos() as u64, Ordering::Relaxed); } restore_microtask_context(); - ran += 1; + *ran += 1; } Some(Task::Microtask { callback, @@ -761,7 +813,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { CURRENT_MICROTASK_NEXT .with(|c| c.set(prev_next_handle.get_raw_mut_ptr::())); restore_microtask_context(); - ran += 1; + *ran += 1; } Some(Task::AsyncStep( step_closure, @@ -822,7 +874,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { pop_async_box_execution_ref(box_activation); } crate::r#box::release_async_box_activation(box_activation); - ran += 1; + *ran += 1; continue; } CURRENT_MICROTASK_CALLBACK.with(|c| c.set(step_closure)); @@ -877,7 +929,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { pop_async_box_execution_ref(box_activation); } crate::r#box::release_async_box_activation(box_activation); - ran += 1; + *ran += 1; continue; } ASYNC_STEP_GUARD.with(|c| { @@ -1034,7 +1086,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { pop_async_box_execution_ref(box_activation); } crate::r#box::release_async_box_activation(box_activation); - ran += 1; + *ran += 1; } } } @@ -1045,10 +1097,10 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { // normal ticks-first ordering. if esm_defer_tick_drain { esm_defer_tick_drain = false; - ran += crate::builtins::drain_queued_microtasks_count(); + *ran += crate::builtins::drain_queued_microtasks_count(); } - if ran == ran_before_checkpoint { + if *ran == ran_before_checkpoint { break; } } @@ -1092,55 +1144,12 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { _ => false, }; if fire_timers { - ran += crate::timer::js_timer_tick(); - ran += crate::timer::js_callback_timer_tick(); - ran += crate::builtins::drain_queued_microtasks_count(); - ran += crate::timer::js_interval_timer_tick(); - } - - crate::exception::js_try_end(); - crate::node_submodules::diagnostics_channel_drain_uncaught(); - - let _ = crate::gc::gc_runtime_safepoint(); - - // Phase 1 of the moving-GC project (see project_gc_one_great_moving_gc): at - // the OUTERMOST microtask-pump boundary the JS stack has fully unwound, so - // there are no live register temporaries and the copying (moving) minor runs - // with precise, rewritable roots — no forced conservative scan. Run it when - // nursery pressure is due so programs that yield to the event loop get - // compacting, O(survivors) young collection instead of the non-moving - // alloc-point fallback. Gated (default off); additive. - if crate::gc::gc_moving_safepoint_enabled() - && MICROTASK_RUN_DEPTH.with(|depth| depth.get().pump) == 1 - { - crate::gc::gc_safepoint_moving_minor(); - } - - // Fallback for release entry points invoked without a tracked plain-async - // activation (principally direct runtime tests). Production async frames - // publish at their own queued/running AsyncStep refcount reaching zero; - // they do not wait for this global pump boundary. - if MICROTASK_RUN_DEPTH.with(|depth| depth.get().pump) == 1 - && TASK_QUEUE.with(|q| q.borrow().is_empty()) - { - crate::r#box::flush_released_boxes(); + *ran += crate::timer::js_timer_tick(); + *ran += crate::timer::js_callback_timer_tick(); + *ran += crate::builtins::drain_queued_microtasks_count(); + *ran += crate::timer::js_interval_timer_tick(); } - ASYNC_BOX_EXECUTION_REF_BASES.with(|bases| { - let base = bases - .borrow_mut() - .pop() - .expect("microtask execution-ref boundary"); - debug_assert_eq!(async_box_execution_ref_depth(), base as usize); - }); - - MICROTASK_RUN_DEPTH.with(|depth| { - let mut current = depth.get(); - current.pump = current.pump.saturating_sub(1); - depth.set(current); - }); - - ran } #[inline(always)] diff --git a/crates/perry-runtime/src/promise/rejection.rs b/crates/perry-runtime/src/promise/rejection.rs index dc3aaf6f19..7cdcb088d8 100644 --- a/crates/perry-runtime/src/promise/rejection.rs +++ b/crates/perry-runtime/src/promise/rejection.rs @@ -376,11 +376,11 @@ fn emit_rejection_handled(promise: *mut Promise) { /// fired from the same drain. fn with_listener_uncaught_trap(f: F) { let trap_buf = crate::exception::js_try_push(); - // SAFETY: this setjmp frame is live only for the synchronous listener - // invocation below; `js_throw` longjmps back here before it is popped. - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut std::os::raw::c_int) }; - if jumped == 0 { - f(); + // The jmp_buf is armed inside a C trampoline frame (#9305). The + // uncaught path below runs only after `js_try_end` pops this trap, so + // a throw out of the `uncaughtException` listener targets the OUTER + // trap — same as the raw shape, which also popped before emitting. + if crate::exception::arm_trap_and_run(trap_buf, f).is_some() { crate::exception::js_try_end(); return; } diff --git a/crates/perry-runtime/src/promise/then.rs b/crates/perry-runtime/src/promise/then.rs index 9f9eb55cb2..fa40bafaa6 100644 --- a/crates/perry-runtime/src/promise/then.rs +++ b/crates/perry-runtime/src/promise/then.rs @@ -1175,17 +1175,10 @@ extern "C" fn then_cap_fulfill_fn( let (result, threw) = if on_ful_cl.is_null() { (value, false) } else { - let trap = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap as *mut std::os::raw::c_int) }; - if jumped == 0 { - let ret = crate::closure::js_closure_call1(on_ful_cl, value); - crate::exception::js_try_end(); - (ret, false) - } else { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::exception::js_try_end(); - (exc, true) + match crate::exception::catch_js_throw(|| crate::closure::js_closure_call1(on_ful_cl, value)) + { + Ok(ret) => (ret, false), + Err(exc) => (exc, true), } }; @@ -1215,17 +1208,10 @@ extern "C" fn then_cap_reject_fn( let (result, threw) = if on_rej_cl.is_null() { (reason, true) // passthrough rejection } else { - let trap = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap as *mut std::os::raw::c_int) }; - if jumped == 0 { - let ret = crate::closure::js_closure_call1(on_rej_cl, reason); - crate::exception::js_try_end(); - (ret, false) - } else { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::exception::js_try_end(); - (exc, true) + match crate::exception::catch_js_throw(|| crate::closure::js_closure_call1(on_rej_cl, reason)) + { + Ok(ret) => (ret, false), + Err(exc) => (exc, true), } }; @@ -1615,23 +1601,22 @@ fn finally_wrapper_common( // is null here (js_promise_finally clears it), so a throw would otherwise // be swallowed. let undef = f64::from_bits(crate::value::TAG_UNDEFINED); - let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut std::os::raw::c_int) }; - if jumped != 0 { - // onFinally threw — reject `next` with the thrown value. - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::exception::js_try_end(); - if !next.is_null() { - js_promise_reject(next, exc); - } - return undef; - } // Spec (Promise.prototype.finally): `onFinally` is invoked with NO // arguments. Calling it with a single `undefined` made `arguments.length` // report 1, failing every finally test that asserts a zero-arg invocation. - let ret = crate::closure::js_closure_call0(on_finally); - crate::exception::js_try_end(); + // (Armed in a C trampoline frame, #9305; the rejection below runs after + // the trap is popped, as before.) + let ret = match crate::exception::catch_js_throw(|| crate::closure::js_closure_call0(on_finally)) + { + Ok(ret) => ret, + Err(exc) => { + // onFinally threw — reject `next` with the thrown value. + if !next.is_null() { + js_promise_reject(next, exc); + } + return undef; + } + }; // If onFinally returned a Promise/thenable, adopt it: wait for it before // settling `next`. `js_assimilate_thenable` returns a native Promise for @@ -1664,19 +1649,16 @@ fn finally_wrapper_common( let on_err_f = f64::from_bits(crate::value::JSValue::pointer(on_err as *const u8).bits()); let args = [on_ok_f, on_err_f]; - let trap2 = crate::exception::js_try_push(); - let jumped2 = unsafe { crate::ffi::setjmp::setjmp(trap2 as *mut std::os::raw::c_int) }; - if jumped2 != 0 { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::exception::js_try_end(); - if !next.is_null() { - js_promise_reject(next, exc); + match crate::exception::catch_js_throw(|| { + call_receiver_then(cleanup, &args); + }) { + Ok(()) => {} + Err(exc) => { + if !next.is_null() { + js_promise_reject(next, exc); + } } - return undef; } - call_receiver_then(cleanup, &args); - crate::exception::js_try_end(); return undef; } } diff --git a/crates/perry-runtime/src/timer.rs b/crates/perry-runtime/src/timer.rs index c703489592..4b2a18059b 100644 --- a/crates/perry-runtime/src/timer.rs +++ b/crates/perry-runtime/src/timer.rs @@ -13,7 +13,6 @@ use crate::promise::{js_promise_new, js_promise_resolve, Promise}; use async_lifecycle::{enqueue_destroy_ids, IntervalCallback}; use std::any::Any; use std::collections::HashMap; -use std::os::raw::c_int; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, LazyLock, Mutex, @@ -478,16 +477,26 @@ fn timer_handle_value(id: i64) -> f64 { fn with_timer_uncaught_trap(f: F) { let trap_buf = crate::exception::js_try_push(); - // SAFETY: this setjmp frame is active only for the synchronous timer - // callback invocation below. `js_throw` longjmps back here before the - // frame is popped, matching the promise microtask runner's trap shape. - let jumped = unsafe { crate::ffi::setjmp::setjmp(trap_buf as *mut c_int) }; - if jumped == 0 { - f(); - } else { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - crate::os::emit_process_uncaught_exception(exc); + let mut f = Some(f); + // The jmp_buf is armed inside a C trampoline frame (#9305 — a raw + // `setjmp` in a Rust frame is unsound). Loop shape: a throw from the + // timer callback lands in the trampoline, and the uncaught path then + // runs under the NEXT arm — so a throw out of an 'uncaughtException' + // listener lands here again instead of targeting a dead frame, + // matching the raw shape where the still-armed setjmp caught it. + loop { + let completed = crate::exception::arm_trap_and_run(trap_buf, || { + if let Some(f) = f.take() { + f(); + } else { + let exc = crate::exception::js_get_exception(); + crate::exception::js_clear_exception(); + crate::os::emit_process_uncaught_exception(exc); + } + }); + if completed.is_some() { + break; + } } crate::exception::js_try_end(); } diff --git a/crates/perry-runtime/src/util_promisify.rs b/crates/perry-runtime/src/util_promisify.rs index 0c0540046f..5fa4c27d98 100644 --- a/crates/perry-runtime/src/util_promisify.rs +++ b/crates/perry-runtime/src/util_promisify.rs @@ -26,7 +26,6 @@ //! transparently but doesn't reach into `this`). use std::cell::Cell; -use std::os::raw::c_int; use crate::array::{js_array_alloc, js_array_length, js_array_push_f64, ArrayHeader}; use crate::closure::{ @@ -34,7 +33,6 @@ use crate::closure::{ js_closure_set_capture_f64, js_closure_set_capture_ptr, js_register_closure_arity, js_register_closure_rest, ClosureHeader, }; -use crate::ffi::setjmp::setjmp; use crate::promise::{ js_promise_attach_handlers, js_promise_new, js_promise_reject, js_promise_resolve, js_value_is_promise, ClosurePtr, Promise, @@ -387,23 +385,37 @@ extern "C" fn outer_thunk(closure: *const ClosureHeader, rest_value: f64) -> f64 // instead of crashing the process. Mirrors the timer / microtask // runners' guard shape. let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { setjmp(trap_buf as *mut c_int) }; - if jumped == 0 { - let arr = combined_handle.get_raw_const_ptr::(); - let data = - unsafe { (arr as *const u8).add(std::mem::size_of::()) as *const f64 }; - let n = js_array_length(arr) as usize; - unsafe { - crate::closure::js_native_call_value(fn_handle.get_nanbox_f64(), data, n); - } - } else { - // #7341: `js_get_exception` can allocate, so pair it with the re-read. - let (exc, promise_after_exc) = promise_handle.across_mut::(|| { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - exc + let mut called = false; + // Armed in a C trampoline frame (#9305). Loop shape: the rejection + // handler runs under a fresh arm, so a throw out of it (promise hooks + // can run JS) lands back here — matching the raw shape, where the + // still-armed setjmp caught it. + loop { + let completed = crate::exception::arm_trap_and_run(trap_buf, || { + if !called { + called = true; + let arr = combined_handle.get_raw_const_ptr::(); + let data = unsafe { + (arr as *const u8).add(std::mem::size_of::()) as *const f64 + }; + let n = js_array_length(arr) as usize; + unsafe { + crate::closure::js_native_call_value(fn_handle.get_nanbox_f64(), data, n); + } + } else { + // #7341: `js_get_exception` can allocate, so pair it with the + // re-read. + let (exc, promise_after_exc) = promise_handle.across_mut::(|| { + let exc = crate::exception::js_get_exception(); + crate::exception::js_clear_exception(); + exc + }); + js_promise_reject(promise_after_exc, exc); + } }); - js_promise_reject(promise_after_exc, exc); + if completed.is_some() { + break; + } } crate::exception::js_try_end(); @@ -493,23 +505,37 @@ extern "C" fn gkp_outer_thunk(closure: *const ClosureHeader, rest_value: f64) -> let combined_handle = scope.root_raw_mut_ptr(combined); let trap_buf = crate::exception::js_try_push(); - let jumped = unsafe { setjmp(trap_buf as *mut c_int) }; - if jumped == 0 { - let arr = combined_handle.get_raw_const_ptr::(); - let data = - unsafe { (arr as *const u8).add(std::mem::size_of::()) as *const f64 }; - let n = js_array_length(arr) as usize; - unsafe { - crate::closure::js_native_call_value(fn_handle.get_nanbox_f64(), data, n); - } - } else { - // #7341: `js_get_exception` can allocate, so pair it with the re-read. - let (exc, promise_after_exc) = promise_handle.across_mut::(|| { - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - exc + let mut called = false; + // Armed in a C trampoline frame (#9305). Loop shape: the rejection + // handler runs under a fresh arm, so a throw out of it (promise hooks + // can run JS) lands back here — matching the raw shape, where the + // still-armed setjmp caught it. + loop { + let completed = crate::exception::arm_trap_and_run(trap_buf, || { + if !called { + called = true; + let arr = combined_handle.get_raw_const_ptr::(); + let data = unsafe { + (arr as *const u8).add(std::mem::size_of::()) as *const f64 + }; + let n = js_array_length(arr) as usize; + unsafe { + crate::closure::js_native_call_value(fn_handle.get_nanbox_f64(), data, n); + } + } else { + // #7341: `js_get_exception` can allocate, so pair it with the + // re-read. + let (exc, promise_after_exc) = promise_handle.across_mut::(|| { + let exc = crate::exception::js_get_exception(); + crate::exception::js_clear_exception(); + exc + }); + js_promise_reject(promise_after_exc, exc); + } }); - js_promise_reject(promise_after_exc, exc); + if completed.is_some() { + break; + } } crate::exception::js_try_end(); From df63c97c3570f893c3c03a0e5a7c14d7307177c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 20:33:26 +0200 Subject: [PATCH 2/6] tests: #9305 regression fixture (throw-in-microtask) + transport unit tests Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- crates/perry-runtime/src/exception.rs | 46 +++++++++++++ .../tests/issue_9305_throw_in_microtask.rs | 67 +++++++++++++++++++ .../test_issue_9305_throw_in_microtask.ts | 65 ++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 crates/perry/tests/issue_9305_throw_in_microtask.rs create mode 100644 test-files/test_issue_9305_throw_in_microtask.ts diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index a892505d7f..f7de598be9 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -890,4 +890,50 @@ mod tests { } assert_eq!(current_try_depth(), base); } + + /// #9305: the C-trampoline transport round-trips a throw. A real + /// `js_throw` longjmps from inside the protected body back into + /// `perry_sjlj_try`'s frame; the Rust caller observes a single-return + /// call and `None`. + #[test] + fn arm_trap_and_run_catches_a_real_throw() { + let base = current_try_depth(); + let env = js_try_push(); + // Normal completion. + assert_eq!(arm_trap_and_run(env, || 7), Some(7)); + // Re-arm the SAME buffer (the run_microtasks shape) and throw. + let landed = arm_trap_and_run(env, || -> i32 { js_throw(42.0) }); + assert!(landed.is_none(), "throw must land in the trampoline"); + assert_eq!(js_get_exception(), 42.0); + js_clear_exception(); + js_try_end(); + assert_eq!(current_try_depth(), base); + } + + /// #9305: `catch_js_throw` = push + arm + pop, Err with the TLS + /// exception cleared. + #[test] + fn catch_js_throw_err_clears_exception() { + let base = current_try_depth(); + assert_eq!(catch_js_throw(|| 3usize), Ok(3)); + let r: Result = catch_js_throw(|| js_throw(7.5)); + assert_eq!(r, Err(7.5)); + // The Err path cleared the slot; a fresh trap sees no exception. + assert_eq!(catch_js_throw(|| 1u8), Ok(1)); + assert_eq!(current_try_depth(), base); + } + + /// #9305: nested arms target the innermost trap; the outer trap still + /// works after the inner one pops. + #[test] + fn nested_trampoline_arms_unwind_innermost_first(){ + let base = current_try_depth(); + let outcome = catch_js_throw(|| { + let inner: Result = catch_js_throw(|| js_throw(1.0)); + assert_eq!(inner, Err(1.0)); + js_throw(2.0) + }); + assert_eq!(outcome, Err(2.0)); + assert_eq!(current_try_depth(), base); + } } diff --git a/crates/perry/tests/issue_9305_throw_in_microtask.rs b/crates/perry/tests/issue_9305_throw_in_microtask.rs new file mode 100644 index 0000000000..4f6a3c3587 --- /dev/null +++ b/crates/perry/tests/issue_9305_throw_in_microtask.rs @@ -0,0 +1,67 @@ +//! #9305: a JS throw inside a microtask longjmp-lands in the microtask +//! runner's trap. Pre-fix the runner armed `setjmp` directly from Rust — +//! rustc cannot express `returns_twice`, so LLVM colored the spilled +//! TLS-base temporary's stack slot into the task-record copy loop, and the +//! landing reloaded a clobbered slot (SIGSEGV, NULL TLS base). The trap now +//! arms inside the C trampoline `perry_sjlj_try` (exception.rs +//! `arm_trap_and_run`), which is immune by construction. +//! +//! NOTE on coverage: the miscompile is an optimized-build phenomenon — the +//! coloring exists in the release-profile runtime archive (it was +//! app-independent: the same libperry_runtime.a crashed every program that +//! threw from a microtask after one task pop). When this test runs against +//! a debug runtime it still pins the routing behavior (rejection of the +//! chained promise, byte-identical drain order vs node); against a release +//! runtime it is the crash regression test. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +#[test] +fn throw_in_microtask_lands_safely_and_matches_node() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = workspace_root().join("test-files/test_issue_9305_throw_in_microtask.ts"); + let output = dir.path().join("main_bin"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-auto-optimize") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled program failed (#9305 regression: a longjmp landing in the \ + microtask runner read a colored stack slot)\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + // Golden captured from node (v26): microtask FIFO order is deterministic. + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "sync-done\ninner-caught:inner\nbenign:2\ncaught:boom-9305\nrecaught:re:first\nsecond:second-landing\n" + ); +} diff --git a/test-files/test_issue_9305_throw_in_microtask.ts b/test-files/test_issue_9305_throw_in_microtask.ts new file mode 100644 index 0000000000..e956b5c263 --- /dev/null +++ b/test-files/test_issue_9305_throw_in_microtask.ts @@ -0,0 +1,65 @@ +// #9305 regression: a JS throw inside a microtask longjmp-lands in the +// microtask runner's trap. Pre-fix, the runner armed `setjmp` directly from +// Rust; rustc cannot express `returns_twice`, so LLVM colored the stack slot +// holding the spilled TLS-base temporary into the task-record copy loop that +// runs on every popped task — the landing then reloaded a clobbered slot and +// crashed (NULL TLS base). The scenario needs (a) popped Task::Promise +// records ahead of the throw and (b) a throw that reaches the runner's trap, +// i.e. a `.then` callback throwing with no try/catch of its own. +// +// Expected output is byte-identical to `node` (golden captured from +// node; microtask FIFO order is deterministic). +const log: string[] = []; + +// Benign microtasks first: each popped task runs the record-copy loop that +// reused the trap's colored slot pre-fix. +Promise.resolve(1) + .then((v) => v + 1) + .then((v) => { + log.push("benign:" + v); + }); + +// Throw from a .then callback — reaches the runner's trap, which must +// reject the chained promise. +Promise.resolve("x") + .then(() => { + throw new Error("boom-9305"); + }) + .catch((e: Error) => { + log.push("caught:" + e.message); + }); + +// Rethrow through a chain: two landings in one drain family. +Promise.reject(new Error("first")) + .catch((e: Error) => { + throw new Error("re:" + e.message); + }) + .catch((e: Error) => { + log.push("recaught:" + e.message); + }); + +// Throw inside a local try inside a microtask: the generated landing pad +// catches it; the runner's trap stays armed and undisturbed. +Promise.resolve().then(() => { + try { + throw new Error("inner"); + } catch (e) { + log.push("inner-caught:" + (e as Error).message); + } +}); + +// queueMicrotask callback that throws AFTER a caught landing in the same +// drain — exercises the trap re-arm path; its rejection routing goes +// through the queued-microtask context restore. +Promise.resolve() + .then(() => { + throw new Error("second-landing"); + }) + .catch((e: Error) => { + log.push("second:" + e.message); + }); + +setTimeout(() => { + console.log(log.join("\n")); +}, 0); +console.log("sync-done"); From c7b5c12f5f6513f6a57d7cc02b30b7b8fc06f3fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 20:34:30 +0200 Subject: [PATCH 3/6] fmt + changelog fragment (#9305) Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- changelog.d/9305-setjmp-c-trampoline.md | 13 +++ crates/perry-runtime/src/exception.rs | 7 +- crates/perry-runtime/src/gc/roots.rs | 16 ++-- crates/perry-runtime/src/object/assert.rs | 1 - .../perry-runtime/src/promise/microtasks.rs | 81 +++++++++---------- crates/perry-runtime/src/promise/then.rs | 30 +++---- 6 files changed, 82 insertions(+), 66 deletions(-) create mode 100644 changelog.d/9305-setjmp-c-trampoline.md diff --git a/changelog.d/9305-setjmp-c-trampoline.md b/changelog.d/9305-setjmp-c-trampoline.md new file mode 100644 index 0000000000..c16dfa9390 --- /dev/null +++ b/changelog.d/9305-setjmp-c-trampoline.md @@ -0,0 +1,13 @@ +### Fixed + +- `cc --help` (and any program throwing from inside a microtask) no longer + segfaults: every jmp_buf the exception transport can `longjmp` to is now + armed inside a dedicated C trampoline (`perry_sjlj_try`) instead of a raw + `setjmp` call in Rust. rustc cannot express `returns_twice`, so a Rust + frame containing a live `setjmp` was compiled under LLVM's one-return + assumption — in `run_microtasks` LLVM colored the stack slot of the + spilled TLS-base temporary into the task-record copy loop, and the + longjmp return path reloaded NULL. The trampoline makes the hazard + unrepresentable for all current and future Rust trap sites; the one + remaining raw `setjmp` (the GC's register-snapshot spill, which never + longjmps) is documented as the deliberate exception. (#9305) diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index f7de598be9..ac4c39fccb 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -326,7 +326,10 @@ pub fn arm_trap_and_run R>(env: *mut i32, f: F) -> Option { let f = ctx.f.take().expect("sjlj trampoline invoked body twice"); ctx.ret = Some(f()); } - let mut ctx: Ctx<_, R> = Ctx { f: Some(f), ret: None }; + let mut ctx: Ctx<_, R> = Ctx { + f: Some(f), + ret: None, + }; // SAFETY: `env` points at a live 256-byte, 16-aligned JmpBuf slab owned // by this thread's exception state; the trampoline's frame stays alive // while `f` runs, so a longjmp from `js_throw` targets a live frame. @@ -926,7 +929,7 @@ mod tests { /// #9305: nested arms target the innermost trap; the outer trap still /// works after the inner one pops. #[test] - fn nested_trampoline_arms_unwind_innermost_first(){ + fn nested_trampoline_arms_unwind_innermost_first() { let base = current_try_depth(); let outcome = catch_js_throw(|| { let inner: Result = catch_js_throw(|| js_throw(1.0)); diff --git a/crates/perry-runtime/src/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs index 08a8900989..5556577df5 100644 --- a/crates/perry-runtime/src/gc/roots.rs +++ b/crates/perry-runtime/src/gc/roots.rs @@ -445,14 +445,14 @@ pub(super) fn mark_stack_roots_unchecked( #[repr(C, align(16))] struct JmpBufWords([u64; 32]); let mut jmp_buf = JmpBufWords([0u64; 32]); // oversized for safety - // The ONE remaining raw `setjmp` call in Rust code, and the one place - // it is sound (#9305): this buffer is never a `longjmp` target — the - // call is a register-spilling trick (setjmp dumps the callee-saved - // registers into the buffer for the conservative scan below) and - // returns exactly once, so LLVM's single-return assumption holds. - // Every jmp_buf that CAN be longjmp'd to is armed through the C - // trampoline `exception::arm_trap_and_run` instead — never add a raw - // `setjmp` whose buffer reaches `js_throw`. + // The ONE remaining raw `setjmp` call in Rust code, and the one place + // it is sound (#9305): this buffer is never a `longjmp` target — the + // call is a register-spilling trick (setjmp dumps the callee-saved + // registers into the buffer for the conservative scan below) and + // returns exactly once, so LLVM's single-return assumption holds. + // Every jmp_buf that CAN be longjmp'd to is armed through the C + // trampoline `exception::arm_trap_and_run` instead — never add a raw + // `setjmp` whose buffer reaches `js_throw`. unsafe { crate::ffi::setjmp::setjmp(jmp_buf.0.as_mut_ptr() as *mut std::os::raw::c_int); } diff --git a/crates/perry-runtime/src/object/assert.rs b/crates/perry-runtime/src/object/assert.rs index 1fde9c779d..624d0f9153 100644 --- a/crates/perry-runtime/src/object/assert.rs +++ b/crates/perry-runtime/src/object/assert.rs @@ -3,7 +3,6 @@ //! Split out of `object/mod.rs` (issue #1103). Pure relocation — no //! logic changes. - use super::*; fn undefined_f64() -> f64 { diff --git a/crates/perry-runtime/src/promise/microtasks.rs b/crates/perry-runtime/src/promise/microtasks.rs index cb3adcc735..78ecd46c4e 100644 --- a/crates/perry-runtime/src/promise/microtasks.rs +++ b/crates/perry-runtime/src/promise/microtasks.rs @@ -354,50 +354,50 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { /// a local. fn pump_protected(mode: MicrotaskDrainMode, reentrant: bool, landed: bool, ran: &mut i32) { if landed { - restore_all_microtask_contexts(); - crate::builtins::restore_queued_microtask_contexts(); - // A microtask's callback threw and unwound here. Read the - // exception, clear it, and reject the `next` promise of the - // microtask that was running. The try frame stays pushed — the - // caller re-arms it for the rest of the drain (js_try_end runs - // after the pump loop completes). - let exc = crate::exception::js_get_exception(); - crate::exception::js_clear_exception(); - let cur = CURRENT_MICROTASK_PROMISE.with(|c| c.replace(std::ptr::null_mut())); - CURRENT_MICROTASK_CALLBACK.with(|c| c.set(std::ptr::null())); - CURRENT_MICROTASK_VALUE.with(|c| c.set(0.0)); - CURRENT_MICROTASK_NEXT.with(|c| c.set(std::ptr::null_mut())); - let unwound_trap = INLINE_TRAP.with(|c| c.replace(InlineTrap::empty())); - // `longjmp` bypasses Rust destructors and normal dispatch tails. Drain - // exactly the activation references acquired since THIS (possibly - // re-entrant) runner began; an enclosing activation is below the saved - // depth and must remain owned when this runner returns. - // Re-read the boundary from TLS after the non-local jump. A Rust local - // held across a landing is not stable (#8937) — this function's frame - // was abandoned by the longjmp; only TLS and memory behind `ran` are. - let async_box_ref_depth = ASYNC_BOX_EXECUTION_REF_BASES.with(|bases| { - *bases - .borrow() - .last() - .expect("microtask execution-ref boundary") - }) as usize; - unwind_async_box_execution_refs(async_box_ref_depth); - if !cur.is_null() { - unsafe { - if !(*cur).next.is_null() { - js_promise_reject((*cur).next, exc); - } + restore_all_microtask_contexts(); + crate::builtins::restore_queued_microtask_contexts(); + // A microtask's callback threw and unwound here. Read the + // exception, clear it, and reject the `next` promise of the + // microtask that was running. The try frame stays pushed — the + // caller re-arms it for the rest of the drain (js_try_end runs + // after the pump loop completes). + let exc = crate::exception::js_get_exception(); + crate::exception::js_clear_exception(); + let cur = CURRENT_MICROTASK_PROMISE.with(|c| c.replace(std::ptr::null_mut())); + CURRENT_MICROTASK_CALLBACK.with(|c| c.set(std::ptr::null())); + CURRENT_MICROTASK_VALUE.with(|c| c.set(0.0)); + CURRENT_MICROTASK_NEXT.with(|c| c.set(std::ptr::null_mut())); + let unwound_trap = INLINE_TRAP.with(|c| c.replace(InlineTrap::empty())); + // `longjmp` bypasses Rust destructors and normal dispatch tails. Drain + // exactly the activation references acquired since THIS (possibly + // re-entrant) runner began; an enclosing activation is below the saved + // depth and must remain owned when this runner returns. + // Re-read the boundary from TLS after the non-local jump. A Rust local + // held across a landing is not stable (#8937) — this function's frame + // was abandoned by the longjmp; only TLS and memory behind `ran` are. + let async_box_ref_depth = ASYNC_BOX_EXECUTION_REF_BASES.with(|bases| { + *bases + .borrow() + .last() + .expect("microtask execution-ref boundary") + }) as usize; + unwind_async_box_execution_refs(async_box_ref_depth); + if !cur.is_null() { + unsafe { + if !(*cur).next.is_null() { + js_promise_reject((*cur).next, exc); } + } + *ran += 1; + } else { + if !unwound_trap.trap_next.is_null() { + js_promise_reject(unwound_trap.trap_next, exc); *ran += 1; } else { - if !unwound_trap.trap_next.is_null() { - js_promise_reject(unwound_trap.trap_next, exc); - *ran += 1; - } else { - crate::node_submodules::diagnostics::schedule_uncaught(exc); - *ran += 1; - } + crate::node_submodules::diagnostics::schedule_uncaught(exc); + *ran += 1; } + } } // Cached profile flag — set once by mt_profile_register() above. @@ -1149,7 +1149,6 @@ fn pump_protected(mode: MicrotaskDrainMode, reentrant: bool, landed: bool, ran: *ran += crate::builtins::drain_queued_microtasks_count(); *ran += crate::timer::js_interval_timer_tick(); } - } #[inline(always)] diff --git a/crates/perry-runtime/src/promise/then.rs b/crates/perry-runtime/src/promise/then.rs index fa40bafaa6..bd8c993212 100644 --- a/crates/perry-runtime/src/promise/then.rs +++ b/crates/perry-runtime/src/promise/then.rs @@ -1175,8 +1175,9 @@ extern "C" fn then_cap_fulfill_fn( let (result, threw) = if on_ful_cl.is_null() { (value, false) } else { - match crate::exception::catch_js_throw(|| crate::closure::js_closure_call1(on_ful_cl, value)) - { + match crate::exception::catch_js_throw(|| { + crate::closure::js_closure_call1(on_ful_cl, value) + }) { Ok(ret) => (ret, false), Err(exc) => (exc, true), } @@ -1208,8 +1209,9 @@ extern "C" fn then_cap_reject_fn( let (result, threw) = if on_rej_cl.is_null() { (reason, true) // passthrough rejection } else { - match crate::exception::catch_js_throw(|| crate::closure::js_closure_call1(on_rej_cl, reason)) - { + match crate::exception::catch_js_throw(|| { + crate::closure::js_closure_call1(on_rej_cl, reason) + }) { Ok(ret) => (ret, false), Err(exc) => (exc, true), } @@ -1606,17 +1608,17 @@ fn finally_wrapper_common( // report 1, failing every finally test that asserts a zero-arg invocation. // (Armed in a C trampoline frame, #9305; the rejection below runs after // the trap is popped, as before.) - let ret = match crate::exception::catch_js_throw(|| crate::closure::js_closure_call0(on_finally)) - { - Ok(ret) => ret, - Err(exc) => { - // onFinally threw — reject `next` with the thrown value. - if !next.is_null() { - js_promise_reject(next, exc); + let ret = + match crate::exception::catch_js_throw(|| crate::closure::js_closure_call0(on_finally)) { + Ok(ret) => ret, + Err(exc) => { + // onFinally threw — reject `next` with the thrown value. + if !next.is_null() { + js_promise_reject(next, exc); + } + return undef; } - return undef; - } - }; + }; // If onFinally returned a Promise/thenable, adopt it: wait for it before // settling `next`. `js_assimilate_thenable` returns a native Promise for From 3f34cebec04924e8b7191faacf405bdc09b3a07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 20:42:28 +0200 Subject: [PATCH 4/6] docs: js_try_push / ffi::setjmp contract notes (#9305) Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- crates/perry-runtime/src/exception.rs | 9 ++++++++- crates/perry-runtime/src/ffi/setjmp.rs | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index ac4c39fccb..a618bcedb0 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -189,7 +189,14 @@ fn with_exception_state(f: impl FnOnce(*mut ExceptionState) -> R) -> R { } /// Push a new try block and return a pointer to its jmp_buf. -/// The generated code must call setjmp() directly with this pointer. +/// +/// The buffer must be armed through the C trampoline +/// (`arm_trap_and_run` / `perry_sjlj_try`), NEVER by a raw `setjmp` call +/// from Rust: rustc cannot express `returns_twice`, so a Rust frame +/// containing a live `setjmp` is miscompiled under LLVM's one-return +/// assumption (#9305 — stack-slot coloring across the call). Generated +/// code does not use this entry point at all (its `try` transport is +/// invoke/landingpad, `js_eh_try_push`, since #7302). #[no_mangle] pub extern "C" fn js_try_push() -> *mut i32 { try_push_with_kind(HandlerKind::Setjmp) diff --git a/crates/perry-runtime/src/ffi/setjmp.rs b/crates/perry-runtime/src/ffi/setjmp.rs index 34ffe3b124..1d382e2099 100644 --- a/crates/perry-runtime/src/ffi/setjmp.rs +++ b/crates/perry-runtime/src/ffi/setjmp.rs @@ -1,5 +1,17 @@ //! Shared `_setjmp` FFI declaration (issue #856). //! +//! ## #9305: this extern must NOT be used to arm a longjmp target +//! +//! rustc cannot express `returns_twice`, so LLVM compiles any Rust caller +//! of this extern under a one-return assumption and may color stack slots +//! across the call — which corrupted `run_microtasks`' longjmp-return path +//! (#9305). The ONLY remaining legitimate caller is the GC's +//! register-snapshot spill (`gc/roots.rs`), which never longjmps and so +//! really is a single-return call. Every jmp_buf that `js_throw` can +//! `longjmp` to must be armed through the C trampoline instead: +//! `exception::arm_trap_and_run` / `perry_sjlj_try` +//! (`src/ffi/perry_sjlj.c`). +//! //! Before this module, both `gc.rs` (register-snapshot path) and `promise.rs` //! (microtask-trap unwind path) declared their own `extern "C" fn setjmp(...)` //! with conflicting parameter types — `*mut u64` vs `*mut i32`. The Rust From 65f964f18c842ffebd513f2c2c10cd4817f4b817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 21:00:40 +0200 Subject: [PATCH 5/6] stdlib+ext-fastify: convert remaining raw setjmp trap sites to the C trampoline (#9305) Same hazard class as the runtime sites: raw setjmp in a Rust frame is compiled without returns_twice. perry-stdlib goes through exception::catch_js_throw; perry-ext-fastify (no Cargo dep on perry-runtime by design) declares the perry_sjlj_try C symbol directly and mirrors arm_trap_and_run locally. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- crates/perry-ext-fastify/src/server.rs | 119 +++++++++++++------- crates/perry-stdlib/src/crypto/random.rs | 12 +- crates/perry-stdlib/src/domain.rs | 28 ++--- crates/perry-stdlib/src/querystring.rs | 12 +- crates/perry-stdlib/src/streams.rs | 31 +---- crates/perry-stdlib/src/streams/writable.rs | 28 +---- 6 files changed, 105 insertions(+), 125 deletions(-) diff --git a/crates/perry-ext-fastify/src/server.rs b/crates/perry-ext-fastify/src/server.rs index 23e9461908..3c771c8e2d 100644 --- a/crates/perry-ext-fastify/src/server.rs +++ b/crates/perry-ext-fastify/src/server.rs @@ -124,15 +124,54 @@ extern "C" { fn js_error_get_message(error: *mut ErrorHeader) -> *mut StringHeader; } -#[cfg(target_vendor = "apple")] extern "C" { - #[link_name = "_setjmp"] - fn setjmp(env: *mut c_int) -> c_int; + /// The runtime's C setjmp trampoline (#9305, `perry_sjlj.c`, bundled in + /// `libperry_runtime.a`). rustc cannot express `returns_twice`, so a raw + /// `setjmp` call in a Rust frame is miscompiled under LLVM's one-return + /// assumption (stack-slot coloring across the call); every jmp_buf arm + /// goes through this C frame instead. Mirrors + /// `perry_runtime::exception::arm_trap_and_run`. + fn perry_sjlj_try( + env: *mut core::ffi::c_void, + body: unsafe extern "C" fn(*mut core::ffi::c_void), + ctx: *mut core::ffi::c_void, + ) -> c_int; } -#[cfg(not(target_vendor = "apple"))] -extern "C" { - fn setjmp(env: *mut c_int) -> c_int; +/// Arm `env` (from `js_try_push`) inside the C trampoline and run `f` under +/// it. `None` = a JS throw longjmp-landed (exception state set, trap still +/// pushed). Local mirror of `perry_runtime::exception::arm_trap_and_run` — +/// this crate deliberately has no Cargo dep on perry-runtime. +fn arm_trap_and_run R>(env: *mut c_int, f: F) -> Option { + struct Ctx { + f: Option, + ret: Option, + } + unsafe extern "C" fn invoke R, R>(raw: *mut core::ffi::c_void) { + let ctx = unsafe { &mut *(raw as *mut Ctx) }; + let f = ctx.f.take().expect("sjlj trampoline invoked body twice"); + ctx.ret = Some(f()); + } + let mut ctx: Ctx<_, R> = Ctx { + f: Some(f), + ret: None, + }; + let rc = unsafe { + perry_sjlj_try( + env as *mut core::ffi::c_void, + invoke::, + &mut ctx as *mut Ctx<_, R> as *mut core::ffi::c_void, + ) + }; + if rc == 0 { + Some( + ctx.ret + .take() + .expect("sjlj trampoline returned 0 without a body result"), + ) + } else { + None + } } /// Opaque marker for the runtime's Promise struct. We never read its @@ -1133,22 +1172,24 @@ fn call_hook_awaiting(hook: ClosurePtr, ctx_f64: f64, ctx_handle: Handle) -> Hoo unsafe fn call_closure2_catching(closure: JsClosure, arg0: f64, arg1: f64) -> ClosureCallResult { let trap_buf = js_try_push(); - let jumped = setjmp(trap_buf); - if jumped != 0 { - let exc = js_get_exception(); - js_clear_exception(); - js_try_end(); - return ClosureCallResult { - value: f64::from_bits(TAG_UNDEFINED), - thrown: Some(exc), - }; - } - - let value = closure.call2(arg0, arg1); - js_try_end(); - ClosureCallResult { - value, - thrown: None, + let outcome = arm_trap_and_run(trap_buf, || unsafe { closure.call2(arg0, arg1) }); + match outcome { + Some(value) => { + js_try_end(); + ClosureCallResult { + value, + thrown: None, + } + } + None => { + let exc = js_get_exception(); + js_clear_exception(); + js_try_end(); + ClosureCallResult { + value: f64::from_bits(TAG_UNDEFINED), + thrown: Some(exc), + } + } } } @@ -1159,22 +1200,24 @@ unsafe fn call_closure3_catching( arg2: f64, ) -> ClosureCallResult { let trap_buf = js_try_push(); - let jumped = setjmp(trap_buf); - if jumped != 0 { - let exc = js_get_exception(); - js_clear_exception(); - js_try_end(); - return ClosureCallResult { - value: f64::from_bits(TAG_UNDEFINED), - thrown: Some(exc), - }; - } - - let value = closure.call3(arg0, arg1, arg2); - js_try_end(); - ClosureCallResult { - value, - thrown: None, + let outcome = arm_trap_and_run(trap_buf, || unsafe { closure.call3(arg0, arg1, arg2) }); + match outcome { + Some(value) => { + js_try_end(); + ClosureCallResult { + value, + thrown: None, + } + } + None => { + let exc = js_get_exception(); + js_clear_exception(); + js_try_end(); + ClosureCallResult { + value: f64::from_bits(TAG_UNDEFINED), + thrown: Some(exc), + } + } } } diff --git a/crates/perry-stdlib/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index 8e9d0f94d5..b2e7b123f7 100644 --- a/crates/perry-stdlib/src/crypto/random.rs +++ b/crates/perry-stdlib/src/crypto/random.rs @@ -734,17 +734,7 @@ mod tests { } fn catch_runtime_throw(f: impl FnOnce()) -> bool { - let env = perry_runtime::exception::js_try_push(); - let jumped = unsafe { perry_runtime::ffi::setjmp::setjmp(env as *mut c_int) }; - if jumped == 0 { - f(); - perry_runtime::exception::js_try_end(); - false - } else { - perry_runtime::exception::js_try_end(); - perry_runtime::exception::js_clear_exception(); - true - } + perry_runtime::exception::catch_js_throw(f).is_err() } #[test] diff --git a/crates/perry-stdlib/src/domain.rs b/crates/perry-stdlib/src/domain.rs index 254c409ee9..e3b5a746a0 100644 --- a/crates/perry-stdlib/src/domain.rs +++ b/crates/perry-stdlib/src/domain.rs @@ -10,7 +10,6 @@ use perry_runtime::{ }; use std::cell::{Cell, RefCell}; use std::collections::HashMap; -use std::os::raw::c_int; use std::sync::Once; // `events` is feature-gated behind `bundled-events`; when the well-known @@ -292,21 +291,18 @@ pub unsafe extern "C" fn js_domain_emit_error( unsafe fn call_with_domain(handle: Handle, callback: f64, args: &[f64]) -> f64 { enter_domain(handle); - let trap_buf = perry_runtime::exception::js_try_push(); - let jumped = perry_runtime::ffi::setjmp::setjmp(trap_buf as *mut c_int); - if jumped == 0 { - let result = - perry_runtime::closure::js_native_call_value(callback, args.as_ptr(), args.len()); - perry_runtime::exception::js_try_end(); - exit_domain(handle); - result - } else { - let err = perry_runtime::exception::js_get_exception(); - perry_runtime::exception::js_clear_exception(); - perry_runtime::exception::js_try_end(); - exit_domain(handle); - let _ = js_domain_emit_error(handle, err, undefined(), true); - undefined() + // Armed in a C trampoline frame (#9305); the error emit below runs + // after the trap is popped, exactly as before. + let outcome = perry_runtime::exception::catch_js_throw(|| unsafe { + perry_runtime::closure::js_native_call_value(callback, args.as_ptr(), args.len()) + }); + exit_domain(handle); + match outcome { + Ok(result) => result, + Err(err) => { + let _ = js_domain_emit_error(handle, err, undefined(), true); + undefined() + } } } diff --git a/crates/perry-stdlib/src/querystring.rs b/crates/perry-stdlib/src/querystring.rs index e979386a95..d363ee4b18 100644 --- a/crates/perry-stdlib/src/querystring.rs +++ b/crates/perry-stdlib/src/querystring.rs @@ -27,7 +27,6 @@ use crate::common::handle::Handle; use std::borrow::Cow; -use std::os::raw::c_int; use perry_runtime::array::{js_array_alloc, js_array_length, js_array_push_f64}; use perry_runtime::buffer::{buffer_alloc, buffer_data_mut, BufferHeader}; @@ -385,16 +384,7 @@ unsafe fn decode_parse_component(raw: &str, decode: Option<*const ClosureHeader> } unsafe fn apply_decode_codec(callback: *const ClosureHeader, raw: &str) -> Option { - let trap_buf = perry_runtime::exception::js_try_push(); - let jumped = unsafe { perry_runtime::ffi::setjmp::setjmp(trap_buf as *mut c_int) }; - let result = if jumped == 0 { - Some(call_codec(callback, raw)) - } else { - perry_runtime::exception::js_clear_exception(); - None - }; - perry_runtime::exception::js_try_end(); - result + perry_runtime::exception::catch_js_throw(|| unsafe { call_codec(callback, raw) }).ok() } /// Call a user-supplied codec closure with `raw` and return its JS value. diff --git a/crates/perry-stdlib/src/streams.rs b/crates/perry-stdlib/src/streams.rs index 0e8d3d590b..8542dc1746 100644 --- a/crates/perry-stdlib/src/streams.rs +++ b/crates/perry-stdlib/src/streams.rs @@ -27,7 +27,6 @@ use perry_runtime::{ArrayHeader, ClosureHeader, JSValue, ObjectHeader, Promise, StringHeader}; use std::collections::{HashMap, VecDeque}; -use std::os::raw::c_int; use std::sync::Mutex; // Calls that allocate or mutate runtime-owned values must cross the stable C @@ -293,18 +292,10 @@ pub(crate) fn internal_promise() -> *mut Promise { } unsafe fn try_call_stream_action(callback: i64, reason: f64) -> Result { - let trap_buf = perry_runtime::exception::js_try_push(); - let jumped = perry_runtime::ffi::setjmp::setjmp(trap_buf as *mut c_int); - if jumped == 0 { - let result = js_closure_call1(callback as *const ClosureHeader, reason); - perry_runtime::exception::js_try_end(); - Ok(result) - } else { - let error = perry_runtime::exception::js_get_exception(); - perry_runtime::exception::js_clear_exception(); - perry_runtime::exception::js_try_end(); - Err(error.to_bits()) - } + perry_runtime::exception::catch_js_throw(|| { + js_closure_call1(callback as *const ClosureHeader, reason) + }) + .map_err(f64::to_bits) } unsafe fn stream_action_promise(result: f64) -> Option<*mut Promise> { @@ -1563,18 +1554,8 @@ unsafe fn call_iterator_next(iterator: f64) -> Option { } unsafe fn try_call_iterator_next(iterator: f64) -> Result, u64> { - let trap_buf = perry_runtime::exception::js_try_push(); - let jumped = perry_runtime::ffi::setjmp::setjmp(trap_buf as *mut c_int); - if jumped == 0 { - let step = call_iterator_next(iterator); - perry_runtime::exception::js_try_end(); - Ok(step) - } else { - let err = perry_runtime::exception::js_get_exception(); - perry_runtime::exception::js_clear_exception(); - perry_runtime::exception::js_try_end(); - Err(err.to_bits()) - } + perry_runtime::exception::catch_js_throw(|| unsafe { call_iterator_next(iterator) }) + .map_err(f64::to_bits) } unsafe fn chunks_from_async_iterable(value: f64) -> Option { diff --git a/crates/perry-stdlib/src/streams/writable.rs b/crates/perry-stdlib/src/streams/writable.rs index e7476851d8..3d564e438f 100644 --- a/crates/perry-stdlib/src/streams/writable.rs +++ b/crates/perry-stdlib/src/streams/writable.rs @@ -351,33 +351,13 @@ unsafe fn attach_writable_write_handlers( } unsafe fn try_call_writable_write(cb: i64, chunk: f64) -> Result { - let trap_buf = perry_runtime::exception::js_try_push(); - let jumped = perry_runtime::ffi::setjmp::setjmp(trap_buf as *mut c_int); - if jumped == 0 { - let result = js_closure_call1(cb as *const ClosureHeader, chunk); - perry_runtime::exception::js_try_end(); - Ok(result) - } else { - let err = perry_runtime::exception::js_get_exception(); - perry_runtime::exception::js_clear_exception(); - perry_runtime::exception::js_try_end(); - Err(err.to_bits()) - } + perry_runtime::exception::catch_js_throw(|| js_closure_call1(cb as *const ClosureHeader, chunk)) + .map_err(f64::to_bits) } unsafe fn try_call_writable_close(cb: i64) -> Result { - let trap_buf = perry_runtime::exception::js_try_push(); - let jumped = perry_runtime::ffi::setjmp::setjmp(trap_buf as *mut c_int); - if jumped == 0 { - let result = js_closure_call0(cb as *const ClosureHeader); - perry_runtime::exception::js_try_end(); - Ok(result) - } else { - let err = perry_runtime::exception::js_get_exception(); - perry_runtime::exception::js_clear_exception(); - perry_runtime::exception::js_try_end(); - Err(err.to_bits()) - } + perry_runtime::exception::catch_js_throw(|| js_closure_call0(cb as *const ClosureHeader)) + .map_err(f64::to_bits) } extern "C" fn writable_close_fulfilled(closure: *const ClosureHeader, _value: f64) -> f64 { From d75e6d0e0927fcda9ebe04c16feed20a52b6e9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 21:40:10 +0200 Subject: [PATCH 6/6] runtime(regex): fancy engine accepts the ASCII word-boundary marker (#9305 fallout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport fix unmasked this second regression: js_regex_to_rust spells ECMAScript's ASCII \b/\B as (?-iu:\b) (#9263), which the regex crate accepts but fancy-regex rejects (NonUnicodeUnsupported). Any lookaround/backreference pattern with a word boundary was a SyntaxError — cli.js's marked html-block regex among them; its throw inside a microtask was the longjmp that the miscompiled runner turned into the --help SIGSEGV. build_fancy_regex now rewrites the translator's marker (unambiguous — '(?-iu:' cannot survive from user input) into the one-char-lookaround boundary spelling the i+u path already uses. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- changelog.d/9305-fancy-ascii-word-boundary.md | 12 +++++ crates/perry-runtime/src/regex.rs | 43 ++++++++++++++- crates/perry-runtime/src/regex/tests.rs | 52 +++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 changelog.d/9305-fancy-ascii-word-boundary.md diff --git a/changelog.d/9305-fancy-ascii-word-boundary.md b/changelog.d/9305-fancy-ascii-word-boundary.md new file mode 100644 index 0000000000..0d4aa121e7 --- /dev/null +++ b/changelog.d/9305-fancy-ascii-word-boundary.md @@ -0,0 +1,12 @@ +### Fixed + +- A `RegExp` combining a lookaround (or backreference) with `\b`/`\B` no + longer throws a bogus `SyntaxError: invalid pattern`: the ASCII + word-boundary spelling `(?-iu:\b)` the translator emits (#9263) is valid + for the linear engine but rejected by fancy-regex's parser, so every + pattern forced onto the fancy engine lost its word boundaries. + `build_fancy_regex` now rewrites the marker into the equivalent + one-code-point-lookaround form. This was the throw inside a microtask + that #9305's setjmp miscompile turned into the `cc --help` segfault; + with both fixed, `marked`'s html-block regex — and `cc --help` — work + again. (#9305) diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 1bb7ebb479..9dda8b6f92 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -358,12 +358,53 @@ pub(crate) fn build_std_regex(pattern: &str) -> Result { .build() } +/// The ASCII word atom the boundary spellings below share. `(?-i:…)` keeps +/// the class exact under an outer `(?i)` — ECMAScript's non-Unicode word set +/// is pure ASCII even case-insensitively (no LONG S / KELVIN SIGN), and the +/// class is already case-closed so disabling the fold changes nothing else. +#[cfg(feature = "regex-engine")] +const FANCY_ASCII_WORD: &str = r"(?-i:[0-9A-Za-z_])"; + +/// Rewrite the translator's ASCII word-boundary markers into a form +/// `fancy-regex` parses (#9305 fallout, unmasked by the transport fix). +/// +/// `js_regex_to_rust` spells ECMAScript's ASCII `\b`/`\B` as `(?-iu:\b)` / +/// `(?-iu:\B)` (#9263). The `regex` crate accepts that scoped flag group, +/// but `fancy-regex`'s own parser rejects the `u` flag outright +/// (`NonUnicodeUnsupported`) — so every pattern that must run on this +/// engine (lookarounds, backreferences) and also contains a word boundary +/// failed to compile as a `SyntaxError`. cli.js's `marked` html-block +/// regex is exactly that shape, which is the throw-in-a-microtask that +/// #9305's setjmp miscompile turned into a segfault. +/// +/// The markers can only come from our own translator — `(?-iu:` is itself +/// a SyntaxError in a JS pattern, so no user input survives translation +/// with that byte sequence outside a character class — making a textual +/// substitution exact. The replacement spells the boundary with +/// one-code-point lookarounds, the same technique +/// `push_unicode_ignore_case_word_boundary` already relies on fancy-regex +/// for: a boundary is "exactly one side is a word char", a non-boundary +/// "both sides agree". +#[cfg(feature = "regex-engine")] +fn fancy_compatible_word_boundaries(pattern: &str) -> String { + if !pattern.contains("(?-iu:") { + return pattern.to_string(); + } + let w = FANCY_ASCII_WORD; + let boundary = format!("(?:(?<={w})(?!{w})|(? Result { - fancy_regex::RegexBuilder::new(pattern) + let pattern = fancy_compatible_word_boundaries(pattern); + fancy_regex::RegexBuilder::new(&pattern) .delegate_size_limit(REGEX_SIZE_LIMIT) .build() } diff --git a/crates/perry-runtime/src/regex/tests.rs b/crates/perry-runtime/src/regex/tests.rs index 85342833e2..ed53eb979d 100644 --- a/crates/perry-runtime/src/regex/tests.rs +++ b/crates/perry-runtime/src/regex/tests.rs @@ -1217,3 +1217,55 @@ fn any_char_rewrite_preserves_match_behaviour() { b"[\\s\\S]+".to_vec() ); } + +/// #9305 fallout: the translator spells ECMAScript's ASCII `\b`/`\B` as +/// `(?-iu:\b)`, which fancy-regex's parser rejects (`NonUnicodeUnsupported`). +/// Any lookaround/backreference pattern containing a word boundary therefore +/// raised a bogus SyntaxError — cli.js's `marked` html-block regex among +/// them, whose throw-in-a-microtask the setjmp miscompile then turned into +/// a segfault. `build_fancy_regex` now rewrites the marker into one-char +/// lookarounds. +#[test] +fn fancy_engine_accepts_ascii_word_boundary_markers() { + // Lookahead + \b: std engine refuses (lookaround), fancy must accept. + let translated = js_regex_to_rust(r"(?!foo\b)\w+"); + let fancy = crate::regex::build_fancy_regex(&translated).expect("fancy build"); + assert_eq!( + fancy.find("foobar").unwrap().map(|m| m.as_str()), + Some("foobar") + ); + assert!(fancy.find("foo bar").unwrap().map(|m| m.as_str()) != Some("foo")); + + // \B variant. + let translated = js_regex_to_rust(r"(?=x)x\Ba"); + let fancy = crate::regex::build_fancy_regex(&translated).expect("fancy \\B build"); + assert!(fancy.is_match("xa").unwrap()); + + // Boundary semantics stay ASCII on the fancy engine: é is NOT a word + // char, so /(?=.)\bé/ must treat the position before é as a boundary + // only when the preceding char is a word char... spec: \b before é + // (non-word) requires previous to be word. + let translated = js_regex_to_rust(r"(?=.)a\b\u00e9"); + let fancy = crate::regex::build_fancy_regex(&translated).expect("fancy ascii build"); + assert!(fancy.is_match("a\u{e9}").unwrap()); + + // The real-world shape: marked's html-block regex from cli_2.1.112.js. + let marked = concat!( + r"^ *(?:|$)) *(?:\n|\s*$)", + r"|<((?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd", + r"|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\b)", + r"\w+(?!:|[^\w\s@]*@)\b)[\s\S]+? *(?:\n{2,}|\s*$)", + r"|<(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd", + r"|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\b)", + r"\w+(?!:|[^\w\s@]*@)\b(?:\x22[^\x22]*\x22|'[^']*'|\s[^'\x22/>\s]*)*?/?> *(?:\n{2,}|\s*$))", + ); + let translated = js_regex_to_rust(marked); + let fancy = crate::regex::build_fancy_regex(&translated).expect("marked html regex build"); + assert_eq!( + fancy + .find("
\nhello\n
\n\n") + .unwrap() + .map(|m| m.as_str()), + Some("
\nhello\n
\n\n") + ); +}