From 7085816c9874c2260acab7eb9cb405a726db0d0e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:56:09 +0200 Subject: [PATCH] #773: run repeating app.scheduleTask tasks until cancelTask Problem: app.scheduleTask(code, delay, true) queued the task and then skipped it (eval_with_tasks: `if repeat { continue; }`), so repeating tasks - what ScriptUI panels use for polling and refreshing - never ran. One-shot tasks scheduled by handlers or in the Script Console never ran either. Fix: tasks live per script context (crates/script/src/tasks.rs) with a due time. Frontends run the due ones every frame through the new ScriptUi::run_tasks hook (the egui app does; agents and headless hosts use the `script.runTasks` command). Script-host threads get a Tick message, inline hosts (Script Console, web) run in their kept context. A repeating task runs every `delay` ms until app.cancelTask, until it throws (reported once, then cancelled), or until its owner goes: the script's last window closing, or - for scripts without windows and the console - the project being closed or replaced (ScriptUi::project_epoch). Bounds: at least 50 ms between runs, at most 64 pending tasks per script, one run per pass (no catch-up bursts), task code nested too deeply is refused, the engine's loop and recursion limits apply. Ticks only reach the hosts of the session that scheduled them. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/engine/src/commands/scripts.rs | 9 ++ crates/engine/src/lib.rs | 1 + crates/engine/src/scriptui.rs | 32 ++++ crates/script/Cargo.toml | 1 + crates/script/src/lib.rs | 8 +- crates/script/src/prelude.js | 2 +- crates/script/src/runtime.rs | 59 +++---- crates/script/src/tasks.rs | 217 ++++++++++++++++++++++++++ crates/script/src/tests_tasks.rs | 180 +++++++++++++++++++++ crates/script/src/ui.rs | 195 +++++++++++++++++++---- crates/ui-egui/src/lib.rs | 19 +++ docs/scripting-parity.md | 1 + 13 files changed, 668 insertions(+), 57 deletions(-) create mode 100644 crates/script/src/tasks.rs create mode 100644 crates/script/src/tests_tasks.rs diff --git a/Cargo.lock b/Cargo.lock index ba84c75f5..9f0403715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1560,6 +1560,7 @@ dependencies = [ "effectcraft-render", "effectcraft-time", "serde_json", + "web-time", ] [[package]] diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 198c9bad9..cc0dbd714 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -384,6 +384,15 @@ pub fn specs() -> Vec { always, settings_save ), + cmd!( + "script.runTasks", + "Run Scheduled Script Tasks", + [], + None, + "{} → runs the app.scheduleTask tasks that are due (frontends do it every frame) → {ran, output, errors, next (ms until the next task, or null)}", + always, + scriptui::run_tasks + ), crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list), crate::query!( "scriptui.get", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9e9b606b..3d08d8750 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -980,6 +980,7 @@ impl Session { self.roto_job = None; self.roto_pending.clear(); self.drop_tasks(); + self.script_ui.project_epoch = self.script_ui.project_epoch.wrapping_add(1); p.fix_next_id(); upgrade_effects(&mut p); self.project = Arc::new(p); diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 88450c990..8049efde7 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -374,6 +374,11 @@ pub struct ScriptUiEvent { /// (`{ok, output, error}`) and updates [`Session::script_ui`]. pub type ScriptUiDispatch = fn(&mut Session, &ScriptUiEvent) -> std::result::Result; +/// Runs the scripts' `app.scheduleTask` tasks that are due; returns +/// `{ran, output, errors: [{message, line, file}], next (ms until the next task is due, or null)}` +/// and updates [`Session::script_ui`]. Frontends call it every frame (see [`ScriptUi::run_tasks`]). +pub type ScriptTaskRunner = fn(&mut Session) -> Value; + /// The open script windows. #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(default)] @@ -384,6 +389,16 @@ pub struct ScriptUi { /// The scripting engine's event hand-off (set with [`Session::script`]). #[serde(skip)] pub dispatch: Option, + /// The scripting engine's scheduled-task runner (set with [`Session::script`]). + #[serde(skip)] + pub tasks: Option, + /// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating + /// tasks of scripts without an open window stop then. + #[serde(skip)] + pub project_epoch: u64, + /// The script contexts of this session with scheduled tasks (kept by the scripting engine). + #[serde(skip)] + pub task_hosts: Vec, } impl ScriptUi { @@ -391,6 +406,17 @@ impl ScriptUi { self.windows.iter().find(|w| w.id == id) } + /// Run the scripts' due `app.scheduleTask` tasks ([`ScriptTaskRunner`]); `None` without a + /// scripting engine. + pub fn run_tasks(s: &mut Session) -> Option { + let f = s.script_ui.tasks?; + // A task may close its ScriptUI panel: forget it then. + let open = crate::commands::scripts::open_panel_names(s); + let r = f(s); + crate::commands::scripts::forget_closed_panels(s, &open); + Some(r) + } + /// Replace the windows a script host published (laying them out). pub fn publish(&mut self, host: u32, mut windows: Vec) { for w in &mut windows { @@ -687,6 +713,12 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result { dispatch(s, ScriptUiEvent { window, widget, kind: kind.into(), value }) } +/// `script.runTasks`: run the scripts' `app.scheduleTask` tasks that are due now (frontends do +/// it every frame; headless hosts and agents call this). +pub(crate) fn run_tasks(s: &mut Session, _: &Value) -> Result { + ScriptUi::run_tasks(s).ok_or_else(|| EngineError::Other("scripting is not available in this build".into())) +} + /// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel). pub(crate) fn close(s: &mut Session, p: &Value) -> Result { let window = window_p(s, p, "scriptui.close")?; diff --git a/crates/script/Cargo.toml b/crates/script/Cargo.toml index ef756107c..093d0c1c4 100644 --- a/crates/script/Cargo.toml +++ b/crates/script/Cargo.toml @@ -18,6 +18,7 @@ effectcraft-render = { workspace = true } effectcraft-path = { workspace = true } serde_json = { workspace = true } boa_engine = { workspace = true } +web-time = { workspace = true } # On the web, boa's clock and Math.random need the browser (getrandom `wasm_js`, web-time). [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index d1c9568c9..dd47b64b3 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -14,7 +14,8 @@ //! ``` //! //! * **Object model** (`prelude.js`): `app` (project, open/newProject, undo groups, -//! `executeCommand`/`findMenuCommandId`, `scheduleTask`, `settings` kept in the settings +//! `executeCommand`/`findMenuCommandId`, `scheduleTask` (one-shot and repeating, see +//! [`run_tasks`]), `settings` kept in the settings //! folder), `Project`, `ItemCollection`, //! `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`, `AVLayer`/`TextLayer`/ //! `ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property` (values, keyframes, @@ -57,11 +58,13 @@ pub mod matchnames; mod model; mod runtime; mod socket; +mod tasks; mod ui; use effectcraft_engine::{ScriptRequest, Session}; pub use runtime::{Outcome, ScriptError, run}; +pub use tasks::{MAX_TASKS, MIN_REPEAT_MS, run_tasks}; pub use ui::dispatch_ui; /// The [`effectcraft_engine::ScriptRunner`] this crate provides. @@ -73,6 +76,7 @@ pub fn runner(s: &mut Session, req: &ScriptRequest) -> serde_json::Value { pub fn install(s: &mut Session) { s.script = Some(runner); s.script_ui.dispatch = Some(dispatch_ui); + s.script_ui.tasks = Some(run_tasks); } /// Run `code` as a one-off script named `name`. @@ -87,6 +91,8 @@ mod tests_ease_presets; #[cfg(test)] mod tests_panels; #[cfg(test)] +mod tests_tasks; +#[cfg(test)] mod tests_ui; #[cfg(test)] mod tests_ui_more; diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f50a968c5..681537cdb 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1804,7 +1804,7 @@ var app = { if (!cmd) throw __err("Unknown command id " + id); return __call(cmd, {}); }, - scheduleTask: function (code, delay, repeat) { return __task("schedule", String(code), !!repeat); }, + scheduleTask: function (code, delay, repeat) { return __task("schedule", String(code), Number(delay), !!repeat); }, cancelTask: function (id) { __task("cancel", id); }, setMemoryUsageLimits: function () {}, setMultiFrameRenderingConfig: function () {}, diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 712999ee0..62a05c427 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -65,18 +65,18 @@ pub(crate) struct Active { pub(crate) output: Vec, /// Open undo groups: (name, project snapshot when the outermost group began). pub(crate) groups: Vec<(String, Arc)>, - /// `app.scheduleTask` queue: (id, code, repeat). - pub(crate) tasks: Vec<(u32, String, bool)>, - pub(crate) next_task: u32, pub(crate) name: String, + /// The script context running (its `app.scheduleTask` tasks are kept under this id, see + /// [`crate::tasks`]). + pub(crate) host: u32, /// Running on a script-host thread: modal dialogs hand the session back to the caller /// through this link while they wait. pub(crate) link: Option, } impl Active { - pub(crate) fn new(session: Session, name: &str) -> Active { - Active { session, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: name.to_string(), link: None } + pub(crate) fn new(session: Session, name: &str, host: u32) -> Active { + Active { session, output: vec![], groups: vec![], name: name.to_string(), host, link: None } } } @@ -268,23 +268,29 @@ pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc) { h.merge_key = None; } -/// `__task("schedule", code, repeat)` → id; `__task("cancel", id)`. +/// `__task("schedule", code, delayMs, repeat)` → id; `__task("cancel", id)` (see +/// [`crate::tasks`]). fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let op = arg_string(args, 0, ctx)?; if op == "cancel" { - let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32; - let _ = with_active(|a| { - a.tasks.retain(|t| t.0 != id); - Ok(()) - }); + let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)?; + if id.is_finite() && id >= 0.0 && id <= f64::from(u32::MAX) { + let _ = with_active(|a| { + crate::tasks::cancel(a.host, id as u32); + Ok(()) + }); + } return Ok(JsValue::undefined()); } let code = arg_string(args, 1, ctx)?; - let repeat = args.get(2).cloned().unwrap_or_default().to_boolean(); + let delay = args.get(2).cloned().unwrap_or_default().to_number(ctx)?; + let repeat = args.get(3).cloned().unwrap_or_default().to_boolean(); + let now = web_time::Instant::now(); let id = with_active(|a| { - a.next_task += 1; - let id = a.next_task; - a.tasks.push((id, code, repeat)); + let id = crate::tasks::schedule(a.host, &a.name, a.session.script_ui.project_epoch, code, delay, repeat, now)?; + if !a.session.script_ui.task_hosts.contains(&a.host) { + a.session.script_ui.task_hosts.push(a.host); + } Ok(id) }) .map_err(throw)?; @@ -415,7 +421,7 @@ pub(crate) fn new_context() -> Result { (js_string!("__query"), 2, native_query), (js_string!("__print"), 1, native_print), (js_string!("__undo"), 2, native_undo), - (js_string!("__task"), 3, native_task), + (js_string!("__task"), 4, native_task), (js_string!("__file"), 3, native_file), (js_string!("__sock"), 3, crate::socket::native_sock), (js_string!("__uiNewId"), 0, crate::ui::native_new_id), @@ -553,14 +559,11 @@ pub(crate) fn settle() -> Vec { .unwrap_or_default() } -/// Evaluate a script file's code, then the one-shot tasks it scheduled (`app.scheduleTask`). -pub(crate) fn eval_with_tasks(ctx: &mut Context, code: &str, file: &str) -> Result { +/// Evaluate a script file's code, then the one-shot tasks it scheduled (`app.scheduleTask`; +/// repeating ones run later, see [`crate::tasks`]). +pub(crate) fn eval_with_tasks(ctx: &mut Context, code: &str, file: &str, host: u32) -> Result { let mut r = eval_in(ctx, code, file); - let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default(); - for (_, code, repeat) in tasks { - if repeat { - continue; - } + for code in crate::tasks::take_one_shots(host) { if let Err(e) = eval_in(ctx, &code, "scheduled task") && r.is_ok() { @@ -660,7 +663,7 @@ pub fn check_nesting(code: &str) -> Result<(), String> { pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome { let host = if req.console { crate::ui::CONSOLE_HOST } else { crate::ui::new_host_id() }; let s = std::mem::take(session); - ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, req.name))); + ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, req.name, host))); let guard = Restore { target: session }; let file = if req.name.is_empty() { "script" } else { req.name }; let (result, windows) = if req.console { @@ -681,10 +684,11 @@ pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome } else { match new_context() { Ok(mut ctx) => { - let r = eval_with_tasks(&mut ctx, req.code, file); + let r = eval_with_tasks(&mut ctx, req.code, file, host); let windows = crate::ui::snapshot(&mut ctx); - if windows.iter().any(|w| w.visible) { - // Palettes and panels outlive the run: keep their context for the handlers. + if windows.iter().any(|w| w.visible) || crate::tasks::has(host) { + // Palettes, panels and repeating tasks outlive the run: keep their context + // for the handlers and tasks. crate::ui::keep_inline(host, ctx); } (r, windows) @@ -694,6 +698,7 @@ pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome }; let output = settle(); drop(guard); + crate::tasks::set_epoch(host, session.script_ui.project_epoch); crate::ui::publish(session, host, req.name, windows); outcome(output, result) } diff --git a/crates/script/src/tasks.rs b/crates/script/src/tasks.rs new file mode 100644 index 000000000..563f3bbc0 --- /dev/null +++ b/crates/script/src/tasks.rs @@ -0,0 +1,217 @@ +//! `app.scheduleTask(code, delay, repeat)` / `app.cancelTask(id)`. +//! +//! Tasks belong to the script context (host, see [`crate::ui`]) that scheduled them and run in +//! it. A script file's one-shot tasks run right after the script; everything else (repeating +//! tasks, and one-shot tasks scheduled later by handlers or other tasks) runs when it is due, as +//! frontends call [`run_tasks`] every frame (`ScriptUi::run_tasks`, `script.runTasks`). +//! +//! Lifetime: a repeating task runs every `delay` ms until `app.cancelTask(id)`, until it throws +//! (the error is reported once and the task cancelled), or until its owner goes away: a script +//! whose windows (ScriptUI panel, palette) were open stops when the last one closes; a script +//! without windows (and the Script Console) keeps its tasks until the project is closed or +//! replaced. Bounds: repeating tasks run at most every [`MIN_REPEAT_MS`], a script has at most +//! [`MAX_TASKS`] pending tasks, every run has the script engine's usual limits, and a due task +//! runs once per pass (no catch-up bursts after a long frame). +//! +//! Each thread keeps the tasks of the hosts it runs: a script-host thread its own, the caller's +//! thread those of inline hosts (the Script Console, web runs). + +use std::cell::RefCell; +use std::collections::BTreeMap; + +use boa_engine::Context; +use effectcraft_engine::Session; +use serde_json::{Value as J, json}; +use web_time::{Duration, Instant}; + +use crate::runtime::{ScriptError, eval_in}; + +/// The shortest interval of a repeating task (milliseconds). +pub const MIN_REPEAT_MS: u64 = 50; +/// The most tasks one script may have pending. +pub const MAX_TASKS: usize = 64; +/// The longest delay (milliseconds), as in browsers' `setTimeout`. +const MAX_DELAY_MS: f64 = 2_147_483_647.0; + +struct Task { + id: u32, + code: String, + interval: Duration, + due: Instant, + repeat: bool, +} + +#[derive(Default)] +struct HostTasks { + next_id: u32, + list: Vec, + /// The script's name (inline hosts without windows run under it). + name: String, + /// [`effectcraft_engine::scriptui::ScriptUi::project_epoch`] when the host last ran (inline + /// hosts; the caller tracks script-host threads). + epoch: u64, +} + +thread_local! { + static TASKS: RefCell> = const { RefCell::new(BTreeMap::new()) }; +} + +/// Queue a task for `host`; its id. +pub(crate) fn schedule(host: u32, name: &str, epoch: u64, code: String, delay_ms: f64, repeat: bool, now: Instant) -> Result { + crate::runtime::check_nesting(&code).map_err(|e| format!("app.scheduleTask: {}", e.trim_start_matches("Error: ")))?; + let ms = if delay_ms.is_finite() { delay_ms.clamp(0.0, MAX_DELAY_MS) } else { 0.0 } as u64; + let interval = Duration::from_millis(if repeat { ms.max(MIN_REPEAT_MS) } else { ms }); + let due = now.checked_add(interval).ok_or("app.scheduleTask: the delay is too long")?; + TASKS.with(|t| { + let mut t = t.borrow_mut(); + let h = t.entry(host).or_default(); + if h.list.len() >= MAX_TASKS { + return Err(format!("app.scheduleTask: a script can have at most {MAX_TASKS} scheduled tasks")); + } + h.next_id = h.next_id.wrapping_add(1).max(1); + let id = h.next_id; + h.list.push(Task { id, code, interval, due, repeat }); + h.name = name.to_string(); + h.epoch = epoch; + Ok(id) + }) +} + +/// `app.cancelTask(id)`. +pub(crate) fn cancel(host: u32, id: u32) { + TASKS.with(|t| { + if let Some(h) = t.borrow_mut().get_mut(&host) { + h.list.retain(|task| task.id != id); + } + }); +} + +/// Take the one-shot tasks of `host` (run right after a script file). +pub(crate) fn take_one_shots(host: u32) -> Vec { + TASKS.with(|t| { + let mut t = t.borrow_mut(); + let Some(h) = t.get_mut(&host) else { return vec![] }; + let (once, keep) = std::mem::take(&mut h.list).into_iter().partition(|task| !task.repeat); + h.list = keep; + once.into_iter().map(|task: Task| task.code).collect() + }) +} + +/// Take the tasks due at `now`: one-shot tasks leave the queue, repeating ones are due again an +/// interval from now. (id, code, repeat). +fn take_due(host: u32, now: Instant) -> Vec<(u32, String, bool)> { + TASKS.with(|t| { + let mut t = t.borrow_mut(); + let Some(h) = t.get_mut(&host) else { return vec![] }; + let mut due = vec![]; + h.list.retain_mut(|task| { + if task.due > now { + return true; + } + due.push((task.id, task.code.clone(), task.repeat)); + match now.checked_add(task.interval) { + Some(next) if task.repeat => { + task.due = next; + true + } + _ => false, + } + }); + due + }) +} + +/// Whether `host` has pending tasks. +pub(crate) fn has(host: u32) -> bool { + TASKS.with(|t| t.borrow().get(&host).is_some_and(|h| !h.list.is_empty())) +} + +/// When `host`'s next task is due. +pub(crate) fn next_due(host: u32) -> Option { + TASKS.with(|t| t.borrow().get(&host).and_then(|h| h.list.iter().map(|task| task.due).min())) +} + +/// Forget `host`'s tasks. +pub(crate) fn drop_host(host: u32) { + TASKS.with(|t| t.borrow_mut().remove(&host)); +} + +/// The hosts on this thread with tasks: (host, script name, epoch). +pub(crate) fn hosts() -> Vec<(u32, String, u64)> { + TASKS.with(|t| t.borrow().iter().filter(|(_, h)| !h.list.is_empty()).map(|(id, h)| (*id, h.name.clone(), h.epoch)).collect()) +} + +pub(crate) fn set_epoch(host: u32, epoch: u64) { + TASKS.with(|t| { + if let Some(h) = t.borrow_mut().get_mut(&host) { + h.epoch = epoch; + } + }); +} + +/// Run `host`'s due tasks in `ctx` (the session is active): how many ran, and the first error. +/// A task that throws is cancelled (a repeating one would only repeat the error). +pub(crate) fn run_due(ctx: &mut Context, host: u32, now: Instant) -> (usize, Option) { + let (mut ran, mut first) = (0, None); + for (id, code, repeat) in take_due(host, now) { + ran += 1; + if let Err(mut e) = eval_in(ctx, &code, "scheduled task") { + if repeat { + cancel(host, id); + e.message = format!("{} (repeating task {id} cancelled)", e.message); + } + let line = e.line.map(|l| format!(" (line {l})")).unwrap_or_default(); + let _ = crate::runtime::with_active(|a| { + a.output.push(format!("Error in scheduled task {id}{line}: {}", e.message)); + Ok(()) + }); + first.get_or_insert(e); + } + } + (ran, first) +} + +/// What a pass over the scheduled tasks did. +#[derive(Default)] +pub(crate) struct Report { + ran: usize, + output: Vec, + errors: Vec, + next: Option, +} + +impl Report { + pub(crate) fn add(&mut self, out: crate::Outcome) { + self.ran += out.result.as_u64().unwrap_or(0) as usize; + self.output.extend(out.output); + self.errors.extend(out.error); + } + + pub(crate) fn due(&mut self, at: Option) { + if let Some(at) = at { + self.next = Some(self.next.map_or(at, |n| n.min(at))); + } + } +} + +/// The [`effectcraft_engine::scriptui::ScriptTaskRunner`] this crate provides: run the tasks +/// that are due now. +pub fn run_tasks(session: &mut Session) -> J { + run_tasks_at(session, Instant::now()) +} + +/// [`run_tasks`] at `now`. +pub(crate) fn run_tasks_at(session: &mut Session, now: Instant) -> J { + let mut report = Report::default(); + // Not from inside a script (`script.runTasks` through `app.executeCommand`). + let running = crate::runtime::ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true)); + if !running { + #[cfg(not(target_arch = "wasm32"))] + crate::ui::tick_threaded(session, now, &mut report); + crate::ui::tick_inline(session, now, &mut report); + crate::ui::prune_task_hosts(session); + } + let errors: Vec = report.errors.iter().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})).collect(); + let next = report.next.map(|n| n.saturating_duration_since(now).as_millis() as u64); + json!({"ran": report.ran, "output": report.output, "errors": errors, "next": next}) +} diff --git a/crates/script/src/tests_tasks.rs b/crates/script/src/tests_tasks.rs new file mode 100644 index 000000000..4ee79984d --- /dev/null +++ b/crates/script/src/tests_tasks.rs @@ -0,0 +1,180 @@ +//! `app.scheduleTask` / `app.cancelTask` (#773): repeating tasks run whenever they are due (frontends +//! tick [`crate::run_tasks`] every frame) until they are cancelled, throw, or their owner goes +//! away; one-shot tasks scheduled later run when due too. + +use std::sync::Arc; + +use effectcraft_engine::{ScriptRequest, Session}; +use serde_json::{Value, json}; +use web_time::{Duration, Instant}; + +use crate::run_code; +use crate::tasks::run_tasks_at; + +fn session() -> Session { + let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() }; + crate::install(&mut s); + s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default())); + s +} + +fn ok(s: &mut Session, code: &str) { + let o = run_code(s, code, "tasks.jsx"); + assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output); +} + +fn console(s: &mut Session, code: &str) -> crate::Outcome { + let o = crate::run(s, &ScriptRequest { code, name: "console", console: true }); + assert!(o.error.is_none(), "console failed: {:?}", o.error); + o +} + +fn ms(n: u64) -> Duration { + Duration::from_millis(n) +} + +fn output(r: &Value) -> Vec { + r["output"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect() +} + +fn comps(s: &Session) -> usize { + s.project.items.values().filter(|i| i.as_comp().is_some()).count() +} + +#[test] +fn a_repeating_task_runs_every_interval_until_cancelled() { + let mut s = session(); + ok( + &mut s, + "var n = 0;\nvar id = app.scheduleTask(\"n++; writeLn('tick ' + n); app.project.items.addComp('C' + n, 64, 64, 1, 1, 25); if (n == 3) app.cancelTask(id);\", 1000, true);", + ); + let t0 = Instant::now(); + // Not due yet: nothing runs, and the frontend learns when to come back. + let r = run_tasks_at(&mut s, t0); + assert_eq!(r["ran"], json!(0)); + assert!(r["next"].as_u64().is_some_and(|n| n <= 1000), "{r}"); + let r = run_tasks_at(&mut s, t0 + ms(1500)); + assert_eq!((r["ran"].clone(), output(&r)), (json!(1), vec!["tick 1".to_string()]), "{r}"); + assert_eq!(comps(&s), 1, "the task edits the project through engine commands"); + // Due again an interval after it ran, once per pass (no catch-up burst). + assert_eq!(run_tasks_at(&mut s, t0 + ms(1500))["ran"], json!(0)); + assert_eq!(run_tasks_at(&mut s, t0 + ms(2600))["ran"], json!(1)); + let r = run_tasks_at(&mut s, t0 + ms(9000)); + assert_eq!(output(&r), vec!["tick 3"]); + // Cancelled: the script (no windows, no tasks) has ended. + let r = run_tasks_at(&mut s, t0 + ms(20_000)); + assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}"); + assert_eq!(comps(&s), 3); + assert!(s.history.undo.len() >= 3, "every run is undoable"); +} + +#[test] +fn a_panels_task_refreshes_it_until_the_panel_closes() { + let mut s = session(); + ok( + &mut s, + r#" +var w = new Window("palette", "Clock"); +var label = w.add("statictext", undefined, "0"); +var n = 0; +app.scheduleTask("n++; label.text = String(n);", 200, true); +w.show(); +"#, + ); + let win = s.script_ui.windows[0].id; + let text = |s: &Session| s.script_ui.window(win).map(|w| w.root.children[0].text.clone()); + let t0 = Instant::now(); + run_tasks_at(&mut s, t0 + ms(500)); + run_tasks_at(&mut s, t0 + ms(1000)); + assert_eq!(text(&s).as_deref(), Some("2"), "the window shows what the task set"); + // A project change doesn't stop a script whose window is open. + s.execute("file.closeProject", json!({})).unwrap(); + assert_eq!(run_tasks_at(&mut s, t0 + ms(1500))["ran"], json!(1)); + assert_eq!(text(&s).as_deref(), Some("3")); + // Closing the window ends the script and its tasks. + s.execute("scriptui.close", json!({"window": win})).unwrap(); + let r = run_tasks_at(&mut s, t0 + ms(5000)); + assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}"); +} + +#[test] +fn a_throwing_task_is_reported_once_and_cancelled() { + let mut s = session(); + ok(&mut s, "app.scheduleTask('undefinedThing.go()', 100, true); app.scheduleTask('writeLn(\"fine\")', 100, true);"); + let t0 = Instant::now(); + let r = run_tasks_at(&mut s, t0 + ms(500)); + assert_eq!(r["ran"], json!(2)); + let errors = r["errors"].as_array().cloned().unwrap_or_default(); + assert_eq!(errors.len(), 1, "{r}"); + assert!(errors[0]["message"].as_str().is_some_and(|m| m.contains("undefinedThing") && m.contains("cancelled")), "{r}"); + // The other task goes on; the failing one doesn't run again. + let r = run_tasks_at(&mut s, t0 + ms(1000)); + assert_eq!((r["ran"].clone(), output(&r), r["errors"].clone()), (json!(1), vec!["fine".to_string()], json!([])), "{r}"); +} + +#[test] +fn tasks_are_bounded() { + let mut s = session(); + // Repeating tasks run at most every MIN_REPEAT_MS. + ok(&mut s, "var hits = 0; app.scheduleTask('hits++', 0, true);"); + let t0 = Instant::now(); + assert_eq!(run_tasks_at(&mut s, t0 + ms(crate::MIN_REPEAT_MS) + ms(20))["ran"], json!(1)); + assert_eq!(run_tasks_at(&mut s, t0 + ms(crate::MIN_REPEAT_MS) + ms(30))["ran"], json!(0)); + // At most MAX_TASKS pending tasks per script. + let o = run_code(&mut s, "for (var i = 0; i < 1000; i++) app.scheduleTask('1', 1000, true);", "many.jsx"); + assert!(o.error.as_ref().is_some_and(|e| e.message.contains(&format!("at most {}", crate::MAX_TASKS))), "{:?}", o.error); + // Task code nested too deeply for the parser is refused up front. + let deep = format!("app.scheduleTask('{}', 10, false);", "(".repeat(10_000)); + let o = run_code(&mut s, &deep, "deep.jsx"); + assert!(o.error.as_ref().is_some_and(|e| e.message.contains("nested too deeply")), "{:?}", o.error); + // Odd delays and ids are harmless. + ok( + &mut s, + "app.cancelTask(-1); app.cancelTask(NaN); app.cancelTask(1e20); app.scheduleTask('1', NaN, false); app.scheduleTask('1', -5, false); app.scheduleTask('1', 1e300, true);", + ); +} + +#[test] +fn closing_the_project_stops_tasks_of_scripts_without_windows() { + let mut s = session(); + ok(&mut s, "app.scheduleTask('writeLn(1)', 100, true);"); + console(&mut s, "var cid = app.scheduleTask('writeLn(2)', 100, true);"); + let t0 = Instant::now(); + assert_eq!(run_tasks_at(&mut s, t0 + ms(500))["ran"], json!(2)); + s.execute("file.closeProject", json!({})).unwrap(); + let r = run_tasks_at(&mut s, t0 + ms(5000)); + assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}"); +} + +#[test] +fn console_tasks_run_in_the_console_and_cancel_there() { + let mut s = session(); + console(&mut s, "var hits = 0; var tid = app.scheduleTask('hits++', 100, true); app.scheduleTask('writeLn(\"once \" + hits)', 300, false);"); + let t0 = Instant::now(); + assert_eq!(run_tasks_at(&mut s, t0 + ms(200))["ran"], json!(1)); + let r = run_tasks_at(&mut s, t0 + ms(400)); + assert_eq!((r["ran"].clone(), output(&r)), (json!(2), vec!["once 2".to_string()]), "{r}"); + assert_eq!(console(&mut s, "app.cancelTask(tid); hits").result, json!(2)); + let r = run_tasks_at(&mut s, t0 + ms(5000)); + assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}"); +} + +#[test] +fn handlers_schedule_one_shot_tasks_that_run_when_due() { + let mut s = session(); + ok( + &mut s, + r#" +var w = new Window("palette", "Later"); +var b = w.add("button", undefined, "Go", { name: "go" }); +b.onClick = function () { app.scheduleTask("writeLn('later')", 100, false); }; +w.show(); +"#, + ); + s.execute("scriptui.click", json!({"widget": "go"})).unwrap(); + let r = run_tasks_at(&mut s, Instant::now() + ms(500)); + assert_eq!(output(&r), vec!["later"], "{r}"); + // The engine command runs the same pass (headless hosts, agents). + assert_eq!(s.execute("script.runTasks", json!({})).unwrap()["ran"], json!(0)); + s.execute("scriptui.close", json!({})).unwrap(); +} diff --git a/crates/script/src/ui.rs b/crates/script/src/ui.rs index 609b767b9..082a16cac 100644 --- a/crates/script/src/ui.rs +++ b/crates/script/src/ui.rs @@ -19,6 +19,7 @@ use boa_engine::{Context, JsResult, JsValue, js_string}; use effectcraft_engine::Session; use effectcraft_engine::scriptui::{ScriptUiEvent, ScriptWindow, layout}; use serde_json::{Value as J, json}; +use web_time::Instant; use crate::runtime::{ACTIVE, Active, CONSOLE, Restore, ScriptError, arg_string, js_str, outcome, script_error, settle, throw, with_active}; @@ -132,6 +133,10 @@ pub(crate) enum ToHost { #[cfg(not(target_arch = "wasm32"))] Start(Session), Event(Session, ScriptUiEvent), + /// Run the scheduled tasks due at this time (see [`crate::tasks`]; the web has no + /// script-host threads). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + Tick(Session, Instant), } impl ToHost { @@ -140,7 +145,7 @@ impl ToHost { match self { #[cfg(not(target_arch = "wasm32"))] ToHost::Start(s) => s, - ToHost::Event(s, _) => s, + ToHost::Event(s, _) | ToHost::Tick(s, _) => s, } } } @@ -152,6 +157,8 @@ pub(crate) struct FromHost { name: String, /// The host thread has ended (no windows left). done: bool, + /// When its next scheduled task is due. + next_due: Option, } /// The host thread's end of the hand-off. @@ -164,6 +171,12 @@ pub(crate) struct Link { struct HostEnd { tx: std::sync::mpsc::Sender, rx: std::sync::mpsc::Receiver, + /// When the host's next scheduled task is due. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + next_due: Option, + /// [`effectcraft_engine::scriptui::ScriptUi::project_epoch`] when the host last ran. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + epoch: u64, } fn hosts() -> &'static std::sync::Mutex> { @@ -177,38 +190,57 @@ const _: fn() = || { send::(); }; +/// What wakes a script-host thread. +enum Wake { + Event(ScriptUiEvent), + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + Tick(Instant), +} + /// On the host thread: hand the session (with the windows' state and what the script printed) -/// back to the caller, and wait for the next event. `None` when the caller has gone. +/// back to the caller, and wait for the next event. `None` when the caller has gone. Scheduled +/// tasks wait while a modal dialog does. fn yield_wait(outcome: crate::Outcome, windows: Vec, done: bool) -> Option { + let mut out = outcome; + loop { + match yield_wake(out, windows.clone(), done, None)? { + Wake::Event(ev) => return Some(ev), + Wake::Tick(_) => out = crate::Outcome { waiting: true, ..Default::default() }, + } + } +} + +/// [`yield_wait`] that also wakes for the scheduled tasks due at `next_due`. +fn yield_wake(outcome: crate::Outcome, windows: Vec, done: bool, next_due: Option) -> Option { let (session, link, name) = ACTIVE.with(|a| { let mut a = a.borrow_mut(); let a = a.as_mut()?; Some((std::mem::take(&mut a.session), a.link.take()?, a.name.clone())) })?; - if link.tx.send(FromHost { session, outcome, windows, name, done }).is_err() { + if link.tx.send(FromHost { session, outcome, windows, name, done, next_due }).is_err() { return None; } if done { return None; } - match link.rx.recv() { - Ok(ToHost::Event(s, ev)) => { - ACTIVE.with(|a| { - if let Some(a) = a.borrow_mut().as_mut() { - a.session = s; - a.link = Some(link); - } - }); - Some(ev) + let (s, wake) = match link.rx.recv() { + Ok(ToHost::Event(s, ev)) => (s, Wake::Event(ev)), + Ok(ToHost::Tick(s, now)) => (s, Wake::Tick(now)), + _ => return None, + }; + ACTIVE.with(|a| { + if let Some(a) = a.borrow_mut().as_mut() { + a.session = s; + a.link = Some(link); } - _ => None, - } + }); + Some(wake) } #[cfg(not(target_arch = "wasm32"))] -fn host_main(name: String, code: String, link: Link) { +fn host_main(host: u32, name: String, code: String, link: Link) { let Ok(ToHost::Start(session)) = link.rx.recv() else { return }; - let mut a = Active::new(session, &name); + let mut a = Active::new(session, &name, host); a.link = Some(link); ACTIVE.with(|c| *c.borrow_mut() = Some(a)); let file = if name.is_empty() { "script".to_string() } else { name.clone() }; @@ -220,19 +252,29 @@ fn host_main(name: String, code: String, link: Link) { return; } }; - let r = crate::runtime::eval_with_tasks(&mut ctx, &code, &file); + let r = crate::runtime::eval_with_tasks(&mut ctx, &code, &file, host); let mut out = outcome(settle(), r); + let mut had_windows = false; loop { let windows = snapshot(&mut ctx); let open = windows.iter().any(|w| w.visible); - match yield_wait(out, windows, !open) { - Some(ev) => { + had_windows |= open; + // The host lives while its windows are open, or (a script that never showed one) + // while it has scheduled tasks. + let done = !open && (had_windows || !crate::tasks::has(host)); + match yield_wake(out, windows, done, crate::tasks::next_due(host)) { + Some(Wake::Event(ev)) => { let r = dispatch_js(&mut ctx, &ev); out = outcome(settle(), r); } - None => return, + Some(Wake::Tick(now)) => { + let (ran, error) = crate::tasks::run_due(&mut ctx, host, now); + out = crate::Outcome { result: json!(ran), output: settle(), error, waiting: false }; + } + None => break, } } + crate::tasks::drop_host(host); })); // A panic: give the session back all the same. let left = ACTIVE.with(|c| c.borrow_mut().take()); @@ -241,21 +283,21 @@ fn host_main(name: String, code: String, link: Link) { { let message = if body.is_err() { "the script engine stopped unexpectedly" } else { "the script ended" }; let out = crate::Outcome { error: Some(ScriptError { message: message.into(), file, ..Default::default() }), ..Default::default() }; - let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true }); + let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true, next_due: None }); } } /// Wait for the host to hand the session back; keep its link while it has windows open. fn receive(session: &mut Session, host: u32, end: HostEnd) -> crate::Outcome { match end.rx.recv() { - Ok(FromHost { session: s, outcome, windows, name, done }) => { + Ok(FromHost { session: s, outcome, windows, name, done, next_due }) => { *session = s; publish(session, host, &name, windows); if done { session.script_ui.windows.retain(|w| w.host != host); session.script_ui.revision += 1; } else if let Ok(mut h) = hosts().lock() { - h.insert(host, end); + h.insert(host, HostEnd { next_due, epoch: session.script_ui.project_epoch, ..end }); } outcome } @@ -271,8 +313,10 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri let (to_tx, to_rx) = std::sync::mpsc::channel(); let (from_tx, from_rx) = std::sync::mpsc::channel(); let (name, code) = (req.name.to_string(), req.code.to_string()); - let spawned = - std::thread::Builder::new().name(format!("script-{host}")).stack_size(64 << 20).spawn(move || host_main(name, code, Link { rx: to_rx, tx: from_tx })); + let spawned = std::thread::Builder::new() + .name(format!("script-{host}")) + .stack_size(64 << 20) + .spawn(move || host_main(host, name, code, Link { rx: to_rx, tx: from_tx })); if spawned.is_err() { return crate::runtime::run_inline(session, req); } @@ -280,7 +324,7 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri *session = msg.into_session(); return crate::Outcome { error: Some(ScriptError { message: "the script host did not start".into(), ..Default::default() }), ..Default::default() }; } - receive(session, host, HostEnd { tx: to_tx, rx: from_rx }) + receive(session, host, HostEnd { tx: to_tx, rx: from_rx, next_due: None, epoch: 0 }) } /// Record the windows a host published (named after the script that made them). @@ -295,8 +339,11 @@ pub(crate) fn publish(session: &mut Session, host: u32, name: &str, mut windows: return; } session.script_ui.publish(host, windows); - if host != CONSOLE_HOST && !session.script_ui.windows.iter().any(|w| w.host == host) { + // Its last window closed: the context and its scheduled tasks go (a script that never showed + // a window keeps them, see [`crate::tasks`]). + if host != CONSOLE_HOST && !session.script_ui.windows.iter().any(|w| w.host == host) && (had || !crate::tasks::has(host)) { drop_inline(host); + crate::tasks::drop_host(host); } } @@ -330,7 +377,7 @@ pub fn dispatch_ui(session: &mut Session, ev: &ScriptUiEvent) -> Result crate::Outcome { let s = std::mem::take(session); - ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name))); + ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name, host))); let guard = Restore { target: session }; let (r, windows) = if host == CONSOLE_HOST { CONSOLE.with(|c| match c.borrow_mut().as_mut() { @@ -354,3 +401,95 @@ fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEv publish(session, host, name, windows); outcome(output, r) } + +// ---------------------------------------------------------------- scheduled tasks + +/// Run the due tasks of the script-host threads; hosts without windows whose project was closed +/// end. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) { + let epoch = session.script_ui.project_epoch; + let (mut due, mut stale) = (vec![], vec![]); + if let Ok(mut h) = hosts().lock() { + for host in session.script_ui.task_hosts.clone() { + let Some(end) = h.get(&host) else { continue }; + let windowless = !session.script_ui.windows.iter().any(|w| w.host == host); + if windowless && end.epoch != epoch { + // Dropping its end lets the host thread finish. + stale.extend(h.remove(&host)); + } else if end.next_due.is_some_and(|d| d <= now) { + due.push(host); + } + } + } + drop(stale); + for host in due { + let Some(end) = hosts().lock().ok().and_then(|mut h| h.remove(&host)) else { continue }; + if let Err(std::sync::mpsc::SendError(msg)) = end.tx.send(ToHost::Tick(std::mem::take(session), now)) { + *session = msg.into_session(); + session.script_ui.windows.retain(|w| w.host != host); + session.script_ui.revision += 1; + continue; + } + report.add(receive(session, host, end)); + } + if let Ok(h) = hosts().lock() { + for host in &session.script_ui.task_hosts { + report.due(h.get(host).and_then(|end| end.next_due)); + } + } +} + +/// Run the due tasks of the inline hosts on this thread (the Script Console, web runs); those +/// without windows whose project was closed stop. +pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) { + for (host, name, epoch) in crate::tasks::hosts() { + if !session.script_ui.task_hosts.contains(&host) { + continue; + } + let windowless = !session.script_ui.windows.iter().any(|w| w.host == host); + let alive = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host)); + if !alive || (windowless && epoch != session.script_ui.project_epoch) { + crate::tasks::drop_host(host); + if host != CONSOLE_HOST && windowless { + drop_inline(host); + } + continue; + } + if crate::tasks::next_due(host).is_some_and(|d| d <= now) { + report.add(tick_inline_host(session, host, &name, now)); + } + if host != CONSOLE_HOST && !crate::tasks::has(host) && !session.script_ui.windows.iter().any(|w| w.host == host) { + // A script without windows whose tasks have all run. + drop_inline(host); + } + report.due(crate::tasks::next_due(host)); + } +} + +fn tick_inline_host(session: &mut Session, host: u32, name: &str, now: Instant) -> crate::Outcome { + let s = std::mem::take(session); + ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name, host))); + let guard = Restore { target: session }; + let run = |ctx: &mut Context| { + let r = crate::tasks::run_due(ctx, host, now); + (r, snapshot(ctx)) + }; + let ((ran, error), windows) = if host == CONSOLE_HOST { + CONSOLE.with(|c| c.borrow_mut().as_mut().map(|ctx| run(ctx))) + } else { + INLINE.with(|m| m.borrow_mut().get_mut(&host).map(|ctx| run(ctx))) + } + .unwrap_or(((0, None), vec![])); + let output = settle(); + drop(guard); + crate::tasks::set_epoch(host, session.script_ui.project_epoch); + publish(session, host, name, windows); + crate::Outcome { result: json!(ran), output, error, waiting: false } +} + +/// Forget the session's task hosts that have ended (inline ones: that have no tasks left). +pub(crate) fn prune_task_hosts(session: &mut Session) { + let threaded = |host: &u32| hosts().lock().ok().is_some_and(|h| h.contains_key(host)); + session.script_ui.task_hosts.retain(|host| crate::tasks::has(*host) || threaded(host)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0e05e007..8dd7600c7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1585,6 +1585,7 @@ impl EffectcraftApp { self.session.poll_camera(true); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } + self.run_script_tasks(&ctx); self.apply_prefs(&ctx); self.handle_events(&ctx); panels::unsaved::on_close_requested(self, &ctx); @@ -1696,6 +1697,24 @@ impl EffectcraftApp { } impl EffectcraftApp { + /// Scripts' `app.scheduleTask` tasks that are due (repeating ones keep a ScriptUI panel + /// fresh): errors as a toast, the last line printed in the status bar, and a repaint when + /// the next one is due. + fn run_script_tasks(&mut self, ctx: &egui::Context) { + if self.session.script_ui.task_hosts.is_empty() { + return; + } + let Some(r) = effectcraft_engine::scriptui::ScriptUi::run_tasks(&mut self.session) else { return }; + if let Some(msg) = r["errors"].as_array().and_then(|e| e.first()).and_then(|e| e["message"].as_str()) { + self.toast = Some((format!("Scheduled script task: {msg}"), ctx.input(|i| i.time))); + } else if let Some(line) = r["output"].as_array().and_then(|o| o.last()).and_then(|l| l.as_str()) { + self.ui.status = line.to_string(); + } + if let Some(next) = r["next"].as_u64() { + ctx.request_repaint_after(std::time::Duration::from_millis(next)); + } + } + /// Auto-save a dirty project when the interval has passed (Settings ▸ Project ▸ Auto-Save). fn tick_autosave(&mut self, ctx: &egui::Context) { let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index fef88efbc..c380025da 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -20,6 +20,7 @@ our own scripts; no real-world script library has been run against both apps. | Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | | Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table | good | `crates/script` tests (≈ 10 test files) | | ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | +| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | | Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) | | Startup / Shutdown folders | yes | not run | missing | plugins.md | | `#include`, `#target` directives | yes | a script starting with one doesn't parse | missing | plugins.md; common in real scripts |