M8.1: expressions keep working on long-lived threads — boa leaves values on its VM stack when an exception unwinds through call frames (every pending host request does), so after tens to hundreds of evaluations that read layers or effects every expression on the thread failed with "maximum stack size"; a run that hits the limit now gets a fresh context and is retried once (regression test: 6000 host-reading evaluations on one thread)

This commit is contained in:
bflatastic committed 2026-10-04 00:19:22 +07:00
1 parent 9fb375e4b0
commit 244cee4687
2 files changed
+37 -3

No files matched your search

+24 -3
View File
@@ -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<usize> {
}
/// 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}")))
})
}
+13
View File
@@ -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}");
}
}