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) <noreply@anthropic.com>
This commit is contained in:
storybored379 2026-10-03 09:31:41 -07:00
parent 4bfb689f19
commit 0ae7a1925a
3 changed files with 30 additions and 2 deletions

View File

@ -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();

View File

@ -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

View File

@ -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();