From 0ae7a1925a0422849d153a734cd99d33ad19ba30 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Sat, 3 Oct 2026 09:31:41 -0700 Subject: [PATCH] Expressions: keep the runtime usable after errors and property reads boa leaves the value stack of inner call frames behind when an exception leaves a script (or a function called from Rust) from a nested call. Every property read throws the pending-host-request abort from inside a prelude helper, and so does any helper error (`thisComp.layer("Ghost")`), so the thread's runtime filled up after ~100 evaluations of a pick-whip expression: from then on every expression on that thread failed with "RuntimeLimitError: reached the maximum stack size" and silently fell back to its keyframed value (e.g. a follower layer stops following after ~90 render_frame calls). Wrap the script and `__finish` in try/catch and rethrow from their own frame, which unwinds the stack; backtraces (error line numbers) survive. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/expr/src/prelude.js | 9 +++++++++ crates/expr/src/runtime.rs | 7 +++++-- crates/expr/src/tests.rs | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index fe95d097..aa202539 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -34,6 +34,15 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { } function __finish(r) { + // Rethrow from this frame so the runtime's stack unwinds (see `Runtime::script`). + try { + return __result(r); + } catch (e) { + throw e; + } +} + +function __result(r) { r = __v(r); if (r !== null && typeof r === 'object' && r.__isLayer) return r.index; if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out(); diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index fe45c268..aa699f42 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -236,8 +236,11 @@ impl Runtime { self.scripts.clear(); } // A block keeps `let`/`const` local to one evaluation; `{` shares line 1 with the code so - // error line numbers match the user's text. - let src = format!("{{{}\n}}", crate::rewrite::rewrite(text)); + // error line numbers match the user's text. Errors are rethrown from the script's own + // frame: boa leaves the stack of the inner frames behind when an exception leaves the + // script from a nested call (every host request does), and the thread's runtime would + // fail every expression with "reached the maximum stack size" after a few hundred. + let src = format!("try{{{}\n}}catch(__e){{throw __e}}", crate::rewrite::rewrite(text)); let s = Script::parse(Source::from_bytes(src.as_bytes()), None, &mut self.ctx).map_err(|e| format_error(&e.to_string(), line_count(text))); self.scripts.insert(text.to_string(), s.clone()); s diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index ac06485e..af7b7868 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -388,6 +388,22 @@ fn circular_references_terminate() { assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}"); } +#[test] +fn errors_and_property_reads_keep_the_runtime_usable() { + // Errors thrown inside helpers, and the aborts every property read makes until the host + // answers, must not use up the thread's runtime (it used to fail every expression after a + // few hundred with "reached the maximum stack size"). + let f = fx(); + for _ in 0..3000 { + assert!(f.ev(f.b, ROT, "thisComp.layer('Ghost').position", 0.0).is_err()); + } + for i in 0..1000 { + let t = i as f64 / 1000.0; + assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').transform.rotation", t).unwrap().as_f64(), 90.0 * t); + } + assert_close!(f.num("time * 2", 1.0), 2.0); +} + #[test] fn effect_slider_reference() { let f = fx();