mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
#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 <[email protected]>
This commit is contained in:
1 parent
9a7d3e7699
commit
7085816c98
13 files changed
+668
-57
No files matched your search
Generated
+1
@@ -1560,6 +1560,7 @@ dependencies = [
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"serde_json",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -384,6 +384,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Value, String>;
|
||||
|
||||
/// 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<ScriptUiDispatch>,
|
||||
/// The scripting engine's scheduled-task runner (set with [`Session::script`]).
|
||||
#[serde(skip)]
|
||||
pub tasks: Option<ScriptTaskRunner>,
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
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<Value> {
|
||||
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<ScriptWindow>) {
|
||||
for w in &mut windows {
|
||||
@@ -687,6 +713,12 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let window = window_p(s, p, "scriptui.close")?;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
@@ -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 () {},
|
||||
|
||||
@@ -65,18 +65,18 @@ pub(crate) struct Active {
|
||||
pub(crate) output: Vec<String>,
|
||||
/// Open undo groups: (name, project snapshot when the outermost group began).
|
||||
pub(crate) groups: Vec<(String, Arc<Project>)>,
|
||||
/// `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<crate::ui::Link>,
|
||||
}
|
||||
|
||||
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<Project>) {
|
||||
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<JsValue> {
|
||||
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<Context, String> {
|
||||
(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<String> {
|
||||
.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<J, ScriptError> {
|
||||
/// 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<J, ScriptError> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<Task>,
|
||||
/// 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<BTreeMap<u32, HostTasks>> = 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<u32, String> {
|
||||
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<String> {
|
||||
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<Instant> {
|
||||
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<ScriptError>) {
|
||||
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<String>,
|
||||
errors: Vec<ScriptError>,
|
||||
next: Option<Instant>,
|
||||
}
|
||||
|
||||
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<Instant>) {
|
||||
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<J> = 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})
|
||||
}
|
||||
@@ -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<String> {
|
||||
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();
|
||||
}
|
||||
+167
-28
@@ -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<Instant>,
|
||||
}
|
||||
|
||||
/// The host thread's end of the hand-off.
|
||||
@@ -164,6 +171,12 @@ pub(crate) struct Link {
|
||||
struct HostEnd {
|
||||
tx: std::sync::mpsc::Sender<ToHost>,
|
||||
rx: std::sync::mpsc::Receiver<FromHost>,
|
||||
/// When the host's next scheduled task is due.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
next_due: Option<Instant>,
|
||||
/// [`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<BTreeMap<u32, HostEnd>> {
|
||||
@@ -177,38 +190,57 @@ const _: fn() = || {
|
||||
send::<Session>();
|
||||
};
|
||||
|
||||
/// 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<ScriptWindow>, done: bool) -> Option<ScriptUiEvent> {
|
||||
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<ScriptWindow>, done: bool, next_due: Option<Instant>) -> Option<Wake> {
|
||||
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<J, Strin
|
||||
|
||||
fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEvent) -> 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));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user