diff --git a/libfjs/src/api/engine.rs b/libfjs/src/api/engine.rs index 6b416a4..7ac9b2a 100644 --- a/libfjs/src/api/engine.rs +++ b/libfjs/src/api/engine.rs @@ -343,12 +343,12 @@ impl JsEngine { self.begin_init()?; let bridge = Arc::new(bridge); + let attachment = self.context.global_attachment.clone(); let init_result = self .context - .ctx - .async_with(async |ctx| { - if let Some(attachment) = &self.context.global_attachment + .with_js(async move |ctx| { + if let Some(attachment) = &attachment && let Err(e) = attachment.attach(&ctx) { return Err(anyhow!("Failed to attach global context: {}", e)); @@ -389,11 +389,11 @@ impl JsEngine { pub async fn init_without_bridge(&self) -> anyhow::Result<()> { self.begin_init()?; + let attachment = self.context.global_attachment.clone(); let init_result = self .context - .ctx - .async_with(async |ctx| { - if let Some(attachment) = &self.context.global_attachment + .with_js(async move |ctx| { + if let Some(attachment) = &attachment && let Err(e) = attachment.attach(&ctx) { return Err(anyhow!("Failed to attach global context: {}", e)); @@ -439,8 +439,7 @@ impl JsEngine { if previous_state == STATE_CREATED || previous_state == STATE_RUNNING { let _ = self .context - .ctx - .async_with(async |ctx| { + .with_js(async |ctx| { let globals = ctx.globals(); let _ = globals.remove("fjs"); Ok::<(), anyhow::Error>(()) @@ -500,8 +499,7 @@ impl JsEngine { let result = self .context - .ctx - .async_with(async |ctx| { + .with_js(async move |ctx| { let res = ctx.eval_with_options(source_code, options.into()); result_from_promise(&ctx, res).await }) @@ -539,8 +537,7 @@ impl JsEngine { let result = self .context - .ctx - .async_with(async |ctx| { + .with_js(async move |ctx| { if is_dynamic_module_loaded(&ctx, &module.name) { return JsResult::Err(JsError::module( Some(module.name), @@ -591,8 +588,7 @@ impl JsEngine { let result = self .context - .ctx - .async_with(async |ctx| { + .with_js(async move |ctx| { let conflicts: Vec<_> = modules .iter() .filter(|module| is_dynamic_module_loaded(&ctx, &module.name)) @@ -682,8 +678,7 @@ impl JsEngine { let result = self .context - .ctx - .async_with(async |ctx| { + .with_js(async move |ctx| { if is_dynamic_module_loaded(&ctx, &module_name) { return JsResult::Err(JsError::module( Some(module_name), @@ -746,8 +741,7 @@ impl JsEngine { let result = self .context - .ctx - .async_with(async |ctx| { + .with_js(async move |ctx| { let conflicts: Vec<_> = resolved_modules .iter() .filter(|(name, _)| is_dynamic_module_loaded(&ctx, name)) @@ -828,8 +822,7 @@ impl JsEngine { let result = self .context - .ctx - .async_with(async |ctx| { + .with_js(async move |ctx| { if is_dynamic_module_loaded(&ctx, &module_name) { return JsResult::Err(JsError::module( Some(module_name), @@ -890,8 +883,7 @@ impl JsEngine { let result = self .context - .ctx - .async_with(async |ctx| { + .with_js(async move |ctx| { if is_dynamic_module_loaded(&ctx, &module_name) { return JsResult::Err(JsError::module( Some(module_name), @@ -1015,8 +1007,7 @@ impl JsEngine { let bytecode = script.bytes; let result = self .context - .ctx - .async_with(async |ctx| { + .with_js(async move |ctx| { let res = eval_script_bytecode(&ctx, &script_name, &bytecode); result_from_maybe_promise(&ctx, res).await }) @@ -1044,8 +1035,7 @@ impl JsEngine { let result = self .context - .ctx - .async_with(async |ctx| { + .with_js(async move |ctx| { if let Some(storage) = ctx.userdata::() { let loaded: HashSet<_> = get_loaded_dynamic_module_names(&ctx).into_iter().collect(); @@ -1083,8 +1073,7 @@ impl JsEngine { self.ensure_running()?; self.context - .ctx - .async_with(async |ctx| { + .with_js(async move |ctx| { if let Some(storage) = ctx.userdata::() { let mut modules: Vec<_> = storage.read().unwrap().keys().cloned().collect(); modules.sort(); @@ -1140,8 +1129,7 @@ impl JsEngine { self.ensure_running()?; self.context - .ctx - .async_with(async |ctx| { + .with_js(async move |ctx| { if let Some(storage) = ctx.userdata::() { Ok(storage.read().unwrap().contains_key(&module_name)) } else { @@ -1225,8 +1213,7 @@ impl JsEngine { let params = params.unwrap_or_default(); let result = self .context - .ctx - .async_with(async |ctx| call_module_method(&ctx, module, method, params).await) + .with_js(async move |ctx| call_module_method(&ctx, module, method, params).await) .await; result.into_result() diff --git a/libfjs/src/api/runtime.rs b/libfjs/src/api/runtime.rs index 11a8fa6..5d0b18f 100644 --- a/libfjs/src/api/runtime.rs +++ b/libfjs/src/api/runtime.rs @@ -16,6 +16,33 @@ use rquickjs::promise::MaybePromise; use rquickjs::{CatchResultExt, FromJs, Module, Promise}; use std::sync::{Arc, RwLock}; +/// Biggest `max_stack_size` for the async runtime: 3/4 of the dedicated JS +/// thread stack, leaving 1/4 as headroom. Also the default budget. +/// +/// QuickJS's overflow check is a soft limit measured from a baseline on the +/// running thread; it only fires after JS grows this many bytes past it and +/// does not know the real native stack. If the budget reaches the thread +/// stack, JS overflows it and the process aborts instead of throwing. The +/// async runtime runs JS on fjs's own big threads (see [`crate::js_executor`]), +/// so this ceiling is generous. Deeper recursion needs a bigger thread stack, +/// not a bigger budget. +pub(crate) const MAX_SAFE_STACK_SIZE: usize = crate::js_executor::JS_THREAD_STACK_SIZE / 4 * 3; + +/// Ceiling for the sync runtime, which runs JS on the caller's thread (fjs does +/// not control its stack). Kept conservative — assumes a 2 MB thread. +const SYNC_MAX_STACK_SIZE: usize = 2 * 1024 * 1024 / 4 * 3; + +/// Clamps a requested stack budget to `ceiling`. `0` means "no limit" to +/// QuickJS, which on a fixed thread stack just means "crash", so it maps to the +/// ceiling too. +fn clamp_stack_size(limit: usize, ceiling: usize) -> usize { + if limit == 0 || limit > ceiling { + ceiling + } else { + limit + } +} + /// Memory usage statistics for the JavaScript runtime. /// /// This struct provides detailed information about memory allocation @@ -182,6 +209,13 @@ fn install_default_async_loaders(runtime: &rquickjs::AsyncRuntime) -> anyhow::Re Ok(()) } +/// The default stack budget for every async runtime. `new()` and `create()` both +/// call this so they can't disagree — without it, `new()` keeps QuickJS's tiny +/// 256 KB default. +async fn apply_default_async_stack_budget(runtime: &rquickjs::AsyncRuntime) { + runtime.set_max_stack_size(MAX_SAFE_STACK_SIZE).await; +} + /// A synchronous JavaScript runtime. /// /// `JsRuntime` provides a synchronous execution environment for JavaScript code. @@ -349,10 +383,13 @@ impl JsRuntime { /// /// ## Parameters /// - /// - `limit`: Maximum stack size in bytes + /// - `limit`: Maximum stack size in bytes. Clamped to a safe ceiling below + /// the runtime thread stack so overflow throws instead of crashing; `0` + /// ("no limit") maps to that ceiling. #[frb(sync)] pub fn set_max_stack_size(&self, limit: usize) { - self.rt.set_max_stack_size(limit); + self.rt + .set_max_stack_size(clamp_stack_size(limit, SYNC_MAX_STACK_SIZE)); } /// Sets the garbage collection threshold. @@ -724,6 +761,8 @@ impl JsAsyncRuntime { pub fn new() -> anyhow::Result { let runtime = rquickjs::AsyncRuntime::new()?; install_default_async_loaders(&runtime)?; + // block_on because new() is sync — same as the loader install above. + futures::executor::block_on(apply_default_async_stack_budget(&runtime)); Ok(Self { rt: runtime, global_attachment: None, @@ -773,6 +812,7 @@ impl JsAsyncRuntime { additional_loader, ); runtime.set_loader(resolver, loader).await; + apply_default_async_stack_budget(&runtime).await; Ok(Self { rt: runtime, @@ -805,9 +845,13 @@ impl JsAsyncRuntime { /// /// ## Parameters /// - /// - `limit`: Maximum stack size in bytes + /// - `limit`: Maximum stack size in bytes. Clamped to a safe ceiling below + /// the runtime thread stack so overflow throws instead of crashing; `0` + /// ("no limit") maps to that ceiling. pub async fn set_max_stack_size(&self, limit: usize) { - self.rt.set_max_stack_size(limit).await; + self.rt + .set_max_stack_size(clamp_stack_size(limit, MAX_SAFE_STACK_SIZE)) + .await; } /// Sets the garbage collection threshold. @@ -903,8 +947,8 @@ impl JsAsyncRuntime { /// } /// ``` pub async fn execute_pending_job(&self) -> anyhow::Result { - self.rt - .execute_pending_job() + let rt = self.rt.clone(); + crate::js_executor::run(async move { rt.execute_pending_job().await }) .await .map_err(|e| anyhow::anyhow!(e)) } @@ -924,7 +968,8 @@ impl JsAsyncRuntime { /// await runtime.idle(); /// ``` pub async fn idle(&self) { - self.rt.idle().await; + let rt = self.rt.clone(); + crate::js_executor::run(async move { rt.idle().await }).await; } /// Sets runtime info string. @@ -966,6 +1011,21 @@ pub struct JsAsyncContext { } impl JsAsyncContext { + /// Runs `f` against this context on a dedicated big-stack JS thread. + /// + /// All user-facing JavaScript runs through here so it gets a browser-class + /// native stack (see [`crate::js_executor`]) instead of flutter_rust_bridge's + /// smaller worker stack, which deep JS (e.g. a recursive render) would + /// overflow. (Context setup in `from` is shallow and runs inline.) + pub(crate) async fn with_js(&self, f: F) -> R + where + F: for<'js> AsyncFnOnce(rquickjs::Ctx<'js>) -> R + Send + 'static, + R: Send + 'static, + { + let ctx = self.ctx.clone(); + crate::js_executor::run(async move { ctx.async_with(f).await }).await + } + /// Creates a new async context from a runtime. /// /// The context will inherit the runtime's module configuration @@ -1062,19 +1122,19 @@ impl JsAsyncContext { /// - If code evaluation fails /// - If global attachment fails pub async fn eval_with_options(&self, code: String, options: JsEvalOptions) -> JsResult { - self.ctx - .async_with(async |ctx| { - if let Some(attachment) = &self.global_attachment - && let Err(e) = attachment.attach(&ctx) - { - return JsResult::Err(JsError::context(e.to_string())); - } - let mut options = options; - options.promise = Some(true); - let res = ctx.eval_with_options(code, options.into()); - result_from_promise(&ctx, res).await - }) - .await + let attachment = self.global_attachment.clone(); + self.with_js(async move |ctx| { + if let Some(attachment) = &attachment + && let Err(e) = attachment.attach(&ctx) + { + return JsResult::Err(JsError::context(e.to_string())); + } + let mut options = options; + options.promise = Some(true); + let res = ctx.eval_with_options(code, options.into()); + result_from_promise(&ctx, res).await + }) + .await } /// Evaluates JavaScript code from a file. @@ -1124,19 +1184,19 @@ impl JsAsyncContext { /// - If file cannot be read /// - If code evaluation fails pub async fn eval_file_with_options(&self, path: String, options: JsEvalOptions) -> JsResult { - self.ctx - .async_with(async |ctx| { - if let Some(attachment) = &self.global_attachment - && let Err(e) = attachment.attach(&ctx) - { - return JsResult::Err(JsError::context(e.to_string())); - } - let mut options = options; - options.promise = Some(true); - let res = ctx.eval_file_with_options(path, options.into()); - result_from_promise(&ctx, res).await - }) - .await + let attachment = self.global_attachment.clone(); + self.with_js(async move |ctx| { + if let Some(attachment) = &attachment + && let Err(e) = attachment.attach(&ctx) + { + return JsResult::Err(JsError::context(e.to_string())); + } + let mut options = options; + options.promise = Some(true); + let res = ctx.eval_file_with_options(path, options.into()); + result_from_promise(&ctx, res).await + }) + .await } /// Evaluates a function from a module. @@ -1176,19 +1236,19 @@ impl JsAsyncContext { params: Option>, ) -> JsResult { let params = params.unwrap_or_default(); - self.ctx - .async_with(async |ctx| { - if let Some(attachment) = &self.global_attachment - && let Err(e) = attachment.attach(&ctx) - { - return JsResult::Err(JsError::context(format!( - "Failed to attach global context: {}", - e - ))); - } - call_module_method(&ctx, module, method, params).await - }) - .await + let attachment = self.global_attachment.clone(); + self.with_js(async move |ctx| { + if let Some(attachment) = &attachment + && let Err(e) = attachment.attach(&ctx) + { + return JsResult::Err(JsError::context(format!( + "Failed to attach global context: {}", + e + ))); + } + call_module_method(&ctx, module, method, params).await + }) + .await } /// Returns all modules currently available in this context. @@ -1196,16 +1256,16 @@ impl JsAsyncContext { /// This includes builtin modules, statically configured modules, /// and any dynamically declared modules attached to the context. pub async fn get_available_modules(&self) -> anyhow::Result> { - self.ctx - .async_with(async |ctx| { - if let Some(attachment) = &self.global_attachment { - attachment - .attach(&ctx) - .map_err(|e| anyhow::anyhow!("Failed to attach global context: {e}"))?; - } - Ok(get_available_module_names(&ctx)) - }) - .await + let attachment = self.global_attachment.clone(); + self.with_js(async move |ctx| { + if let Some(attachment) = &attachment { + attachment + .attach(&ctx) + .map_err(|e| anyhow::anyhow!("Failed to attach global context: {e}"))?; + } + Ok(get_available_module_names(&ctx)) + }) + .await } } diff --git a/libfjs/src/js_executor.rs b/libfjs/src/js_executor.rs new file mode 100644 index 0000000..a1b260e --- /dev/null +++ b/libfjs/src/js_executor.rs @@ -0,0 +1,59 @@ +//! Dedicated runtime that runs all JavaScript on big-stack threads. +//! +//! flutter_rust_bridge runs Rust on tokio workers whose stack defaults to 2 MB +//! and which fjs cannot resize. That is far below a browser's ~8 MB JS stack, +//! so deep-but-normal JS (e.g. a recursive UI render) can overflow the native +//! stack and abort the whole process instead of throwing. To behave like a +//! browser, fjs runs every JS entry on its own runtime whose threads have an +//! 8 MB stack. QuickJS refreshes its overflow baseline on each entry, so +//! spreading JS across these threads stays correct. +//! +//! The runtime is process-global and lives for the whole app, so it is never +//! dropped in an async context (which would panic). + +use std::future::Future; +use std::sync::OnceLock; +use tokio::runtime::Runtime; +use tokio::task::JoinHandle; + +/// Native stack of the dedicated JS threads. Browser-class, so deep UI trees +/// render; [`crate::api::runtime::MAX_SAFE_STACK_SIZE`] keeps the JS budget a +/// safe fraction below it. +pub(crate) const JS_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024; + +/// Number of JS worker threads. JS is serialized per runtime by QuickJS's lock, +/// so a small pool is plenty; it mainly lets timers/fetch and separate engines +/// make progress in parallel. +const JS_WORKER_THREADS: usize = 4; + +fn executor() -> &'static Runtime { + static EXECUTOR: OnceLock = OnceLock::new(); + EXECUTOR.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(JS_WORKER_THREADS) + .thread_stack_size(JS_THREAD_STACK_SIZE) + .thread_name("fjs-js") + .enable_all() + .build() + .expect("failed to build fjs JS runtime") + }) +} + +/// Spawns a JS task on the dedicated runtime. +pub(crate) fn spawn(future: F) -> JoinHandle +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + executor().spawn(future) +} + +/// Runs `future` on the dedicated runtime and awaits it, so the JS executes on +/// a big-stack thread no matter which runtime called us. +pub(crate) async fn run(future: F) -> F::Output +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + spawn(future).await.expect("fjs JS task panicked") +} diff --git a/libfjs/src/lib.rs b/libfjs/src/lib.rs index 4da1437..a74a5a2 100644 --- a/libfjs/src/lib.rs +++ b/libfjs/src/lib.rs @@ -39,6 +39,7 @@ pub mod api; mod bytecode_support; mod frb_generated; +mod js_executor; #[cfg(test)] mod tests; diff --git a/libfjs/src/tests/mod.rs b/libfjs/src/tests/mod.rs index 0408ad6..4e16c67 100644 --- a/libfjs/src/tests/mod.rs +++ b/libfjs/src/tests/mod.rs @@ -31,6 +31,9 @@ mod memory_tests; #[cfg(test)] mod llrt_module_tests; +#[cfg(test)] +mod stack_tests; + /// Test helper utilities #[cfg(test)] pub mod test_utils { diff --git a/libfjs/src/tests/stack_tests.rs b/libfjs/src/tests/stack_tests.rs new file mode 100644 index 0000000..9fb69c8 --- /dev/null +++ b/libfjs/src/tests/stack_tests.rs @@ -0,0 +1,224 @@ +//! # Stack-overflow safety +//! +//! QuickJS's overflow check is a soft limit: it only fires once JS has grown +//! `max_stack_size` bytes past a baseline captured on the running thread, and +//! it does not know the real native thread stack. Two things must hold: +//! +//! * eval and pumped jobs must run against a fresh per-thread baseline, or one +//! path would overflow much earlier than the other; +//! * `max_stack_size` must stay below the thread stack, or JS overflows the +//! native stack first and the process aborts instead of throwing. +//! +//! fjs runs all JS on a dedicated runtime whose threads have a large (8 MB) +//! stack, defaults the budget generously under that, and clamps any larger +//! request. These tests pin that down: JS runs on the dedicated thread, eval +//! and jobs reach the same catchable depth, a bigger budget reaches deeper, the +//! default budget is generous, and an over-large budget is clamped so it still +//! throws instead of crashing. +//! +//! A native stack overflow aborts the whole test binary, so a regression here +//! shows up as a process abort, not a normal assertion failure. + +use crate::api::engine::JsEngine; +use crate::api::runtime::{JsAsyncContext, JsAsyncRuntime}; +use crate::api::source::JsCode; +use crate::api::value::JsValue; + +const PROBE_DEF: &str = r#" +globalThis.__probe = function () { + let depth = 0; + function r() { depth++; r(); } + try { r(); } catch (e) { + if (!(e instanceof RangeError)) throw e; + } + return depth; +}; +"#; + +/// Self-contained version of the probe, returning the depth reached. +const PROBE_IIFE: &str = r#" +(function () { + let depth = 0; + function r() { depth++; r(); } + try { r(); } catch (e) { + if (!(e instanceof RangeError)) throw e; + } + return depth; +})() +"#; + +/// Returns the recursion depth reached via (eval, pumped job) for a runtime. +/// +/// Both paths go through fjs's real entry points: `with_js` (which runs JS on +/// the dedicated thread) and `execute_pending_job`. The job is a queued +/// microtask that is only run when pumped, so it exercises the job path. +async fn eval_and_job_depths(runtime: &JsAsyncRuntime, context: &JsAsyncContext) -> (i32, i32) { + context + .with_js(async |ctx| { + ctx.eval::<(), _>(PROBE_DEF).unwrap(); + }) + .await; + + let eval_depth = context + .with_js(async |ctx| ctx.eval::("__probe()").unwrap()) + .await; + + context + .with_js(async |ctx| { + ctx.eval::<(), _>( + "globalThis.__jd = -1; \ + Promise.resolve().then(() => { globalThis.__jd = __probe(); });", + ) + .unwrap(); + }) + .await; + + while runtime.execute_pending_job().await.unwrap() {} + + let job_depth = context + .with_js(async |ctx| ctx.eval::("globalThis.__jd").unwrap()) + .await; + + (eval_depth, job_depth) +} + +async fn runtime_with_budget(budget: usize) -> (JsAsyncRuntime, JsAsyncContext) { + let runtime = JsAsyncRuntime::new().unwrap(); + runtime.set_max_stack_size(budget).await; + let context = JsAsyncContext::from(&runtime).await.unwrap(); + (runtime, context) +} + +/// JS runs on fjs's dedicated big-stack thread, not the calling tokio worker. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_runs_on_dedicated_big_stack_thread() { + let runtime = JsAsyncRuntime::new().unwrap(); + let context = JsAsyncContext::from(&runtime).await.unwrap(); + + let thread_name = context + .with_js(async |_ctx| std::thread::current().name().map(str::to_string)) + .await; + + assert!( + thread_name.as_deref().unwrap_or("").starts_with("fjs-js"), + "JS should run on the dedicated fjs-js thread, got {thread_name:?}", + ); +} + +/// Eval and a pumped job reach the same depth and both throw a catchable +/// error (rquickjs refreshes the stack baseline on each entry, including jobs). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn eval_and_job_reach_same_catchable_depth() { + let (runtime, context) = runtime_with_budget(256 * 1024).await; + let (eval_depth, job_depth) = eval_and_job_depths(&runtime, &context).await; + + assert!( + eval_depth > 0 && job_depth > 0, + "both paths must throw a catchable RangeError (no crash), got eval={eval_depth} job={job_depth}", + ); + assert!( + (eval_depth - job_depth).abs() <= 3, + "eval and job should reach ~the same depth, got eval={eval_depth} job={job_depth}", + ); +} + +/// A bigger budget (within the safe ceiling) reaches deeper on both paths. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn raising_budget_raises_depth_on_both_paths() { + let (small_rt, small_ctx) = runtime_with_budget(128 * 1024).await; + let small = eval_and_job_depths(&small_rt, &small_ctx).await; + + let (large_rt, large_ctx) = runtime_with_budget(512 * 1024).await; + let large = eval_and_job_depths(&large_rt, &large_ctx).await; + + assert!( + large.0 > small.0, + "eval: larger budget should reach deeper ({} vs {})", + large.0, + small.0, + ); + assert!( + large.1 > small.1, + "job: larger budget should reach deeper ({} vs {})", + large.1, + small.1, + ); +} + +/// The default budget (set by `create`) reaches far deeper than QuickJS's +/// 256 KB default, and the recursion runs to a catchable throw rather than +/// crashing the ~2 MB tokio worker the test runs on — proof the JS ran on the +/// dedicated 8 MB thread. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn default_budget_is_generous_and_routed() { + let engine = JsEngine::create(None, None, None).await.unwrap(); + engine.init_without_bridge().await.unwrap(); + + let depth = engine + .eval(JsCode::Code(PROBE_IIFE.to_string()), None) + .await + .unwrap(); + + match depth { + JsValue::Integer(d) => assert!( + d > 100, + "default budget should reach far deeper than the 256 KB default, got {d}", + ), + other => panic!("expected an integer depth, got {other:?}"), + } +} + +/// `new()` and `create()` must give the same default budget. `new()` used to +/// skip it and fall back to QuickJS's 256 KB default, so with no explicit budget +/// it overflowed far shallower — caught here by comparing recursion depth. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn new_and_create_apply_the_same_default_budget() { + // No explicit `set_max_stack_size` on either — each must inherit the default. + let new_rt = JsAsyncRuntime::new().unwrap(); + let new_ctx = JsAsyncContext::from(&new_rt).await.unwrap(); + let (new_depth, _) = eval_and_job_depths(&new_rt, &new_ctx).await; + + let create_rt = JsAsyncRuntime::create(None, None).await.unwrap(); + let create_ctx = JsAsyncContext::from(&create_rt).await.unwrap(); + let (create_depth, _) = eval_and_job_depths(&create_rt, &create_ctx).await; + + assert!( + new_depth > 0 && create_depth > 0, + "both constructors must reach a catchable depth, got new={new_depth} create={create_depth}", + ); + // Same default budget => ~same depth. Before the fix `new()` was the ~256 KB + // default and bottomed out many times shallower than `create()`. + let ratio = new_depth as f64 / create_depth as f64; + assert!( + ratio > 0.8, + "new() should reach ~the same depth as create() (shared default budget), \ + got new={new_depth} create={create_depth} (ratio {ratio:.2})", + ); +} + +/// An over-large budget is clamped below the JS thread stack, so deep recursion +/// throws instead of aborting the process. +/// +/// Without the clamp, the 64 MB budget lets JS blow past the 8 MB JS thread and +/// the test binary aborts. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_budget_is_clamped_and_stays_catchable() { + let runtime = JsAsyncRuntime::new().unwrap(); + runtime.set_max_stack_size(64 * 1024 * 1024).await; + let context = JsAsyncContext::from(&runtime).await.unwrap(); + + context + .with_js(async |ctx| { + ctx.eval::<(), _>(PROBE_DEF).unwrap(); + }) + .await; + + let depth = context + .with_js(async |ctx| ctx.eval::("__probe()").unwrap()) + .await; + + assert!( + depth > 0, + "clamped budget must throw a catchable RangeError (no crash), got depth {depth}", + ); +}