diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index fe45c268..24416b89 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -16,6 +16,8 @@ const PRELUDE: &str = include_str!("prelude.js"); /// Message of the error thrown when a request isn't answered yet (never shown to users). const PENDING: &str = "\u{1}effectcraft: pending host request"; const MAX_SCRIPTS: usize = 4096; +/// The part of boa's stack-size limit error the runtime recovers from (see [`run`]). +const STACK_LIMIT: &str = "maximum stack size"; /// Memoized host answers and pending misses of one expression evaluation. #[derive(Default)] @@ -392,13 +394,32 @@ fn position_line(msg: &str) -> Option { } /// Run `text` once on this thread's runtime. +/// +/// boa leaves values on its VM stack when an exception unwinds through call frames, and every +/// pending host request does that (the request throws out of the object-model functions), so a +/// long-lived context eventually fails every run with "maximum stack size". When a run hits that +/// limit the context is replaced with a fresh one and the run retried once: each run starts from +/// `__begin` with memoized answers, so nothing carries over. A script that really overflows the +/// stack fails again on the fresh context and reports the error. pub fn run(text: &str, b: &Begin) -> Run { RUNTIME.with(|r| { let mut r = r.borrow_mut(); - match &mut **r.get_or_insert_with(|| ManuallyDrop::new(Runtime::new())) { - Ok(rt) => rt.run(text, b), - Err(e) => Run::Done(Err(e.clone())), + for fresh in [false, true] { + let out = match &mut **r.get_or_insert_with(|| ManuallyDrop::new(Runtime::new())) { + Ok(rt) => rt.run(text, b), + Err(e) => return Run::Done(Err(e.clone())), + }; + match out { + Run::Done(Err(e)) if !fresh && e.contains(STACK_LIMIT) => { + // Dropped here, mid-thread, where boa's heap is alive (unlike at thread exit). + if let Some(old) = r.take() { + drop(ManuallyDrop::into_inner(old)); + } + } + out => return out, + } } + Run::Done(Err(format!("Error: {STACK_LIMIT}"))) }) } diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index ac06485e..1f8ae7a2 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1131,3 +1131,16 @@ fn project_and_time_globals() { assert_close!(f.num("thisLayer.marker.key(1).protectedRegion ? 1 : 0", 0.0), 1.0); assert_eq!(f.text("thisComp.layer(\"A\").marker.key(1).cuePointName + thisComp.layer(\"A\").marker.key(1).parameters.k", 0.0), "gov"); } + +#[test] +fn thousands_of_host_reads_on_one_thread_keep_working() { + // Every pending host request unwinds through call frames, which leaks boa stack slots; the + // runtime replaces a context that fills up instead of failing every later expression. + let f = fx(); + for i in 0..2000 { + let t = (i % 20) as f64 * 0.05; + assert_eq!(f.num("effect(\"Slider Control\")(\"Slider\")", t), 42.0, "evaluation {i}"); + assert!(close(f.num("thisComp.layer(\"A\").transform.rotation.valueAtTime(1)", t), 90.0), "evaluation {i}"); + assert_eq!(f.num("function a() { return effect(1)(1); } function b() { return a(); } b() + 1", t), 43.0, "evaluation {i}"); + } +}