Keep native control renders responsive (#3306)

* Keep native control renders responsive

* Refresh replies for immediately completed render jobs

* #3289: Bind waiting replies to their native render
This commit is contained in:
FredApps authored and GitHub committed 2026-11-08 16:43:06 +01:00
1 parent 07be19d402
commit 86c08aa0ee
20 files changed
+950 -29

No files matched your search

+1
View File
@@ -79,6 +79,7 @@ pub fn install(s: &mut Session) {
s.script = Some(runner);
s.script_ui.dispatch = Some(dispatch_ui);
s.script_ui.tasks = Some(run_tasks);
s.script_ui.poll_render = Some(ui::poll_render_wait);
}
/// Run `code` as a one-off script named `name`.
+24 -2
View File
@@ -30,6 +30,8 @@ pub struct Outcome {
/// The script is waiting in a modal ScriptUI dialog's `show()` (it goes on when the dialog
/// closes; see [`crate::dispatch_ui`]).
pub waiting: bool,
/// The native frontend owns the session while this host waits for its render worker.
pub render_host: Option<u32>,
}
/// An uncaught script error.
@@ -55,6 +57,9 @@ impl Outcome {
if self.waiting {
j["waiting"] = json!(true);
}
if let Some(host) = self.render_host {
j["renderHost"] = json!(host);
}
j
}
}
@@ -74,13 +79,16 @@ pub(crate) struct Active {
/// 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>,
/// Captured at invocation start: the frontend resets its opt-in when the caller returns.
pub(crate) yield_renders: bool,
/// The files `$.evalFile` is evaluating, outermost first.
pub(crate) loading: Vec<std::path::PathBuf>,
}
impl Active {
pub(crate) fn new(session: Session, name: &str, host: u32) -> Active {
Active { session, output: vec![], groups: vec![], name: name.to_string(), host, link: None, loading: vec![] }
let yield_renders = session.script_ui.yield_renders;
Active { session, output: vec![], groups: vec![], name: name.to_string(), host, link: None, yield_renders, loading: vec![] }
}
}
@@ -130,6 +138,13 @@ fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
return Err(throw("scripts can't run scripts"));
}
let keys = params.as_object_mut().and_then(|o| o.remove("__keys"));
let yield_render = id == "renderQueue.render"
&& params.get("wait").and_then(J::as_bool).unwrap_or(true)
&& with_active(|a| Ok(a.yield_renders && a.link.is_some())).unwrap_or(false);
if yield_render {
let object = params.as_object_mut().ok_or_else(|| throw("renderQueue.render needs an object of parameters"))?;
object.insert("wait".into(), json!(false));
}
let r = with_active(|a| {
Ok(match &keys {
Some(k) => exec_on_keys(&mut a.session, &id, params, k),
@@ -137,7 +152,10 @@ fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
})
});
match r {
Ok(Ok(v)) => Ok(js_str(&v.to_string())),
Ok(Ok(v)) => {
let v = if yield_render { crate::ui::wait_for_render(ctx, v).map_err(throw)? } else { v };
Ok(js_str(&v.to_string()))
}
Ok(Err(e)) | Err(e) => Err(throw(e)),
}
}
@@ -718,6 +736,10 @@ pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
};
let Some(x) = expanded else { return run_expanded(session, req) };
let mut out = run_expanded(session, &ScriptRequest { code: &x.code, name: req.name, console: req.console });
if let Some(host) = out.render_host {
crate::ui::keep_render_source_map(host, x);
return out;
}
if let Some(e) = out.error.as_mut() {
x.locate(e);
}
+3 -1
View File
@@ -208,7 +208,9 @@ pub(crate) fn run_tasks_at(session: &mut Session, now: Instant) -> J {
if !running {
#[cfg(not(target_arch = "wasm32"))]
crate::ui::tick_threaded(session, now, &mut report);
crate::ui::tick_inline(session, now, &mut report);
if session.script_ui.render_host.is_none() {
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();
+2
View File
@@ -15,6 +15,8 @@ mod ease_presets;
mod evalfile;
mod include;
mod panels;
#[cfg(not(target_arch = "wasm32"))]
mod render_wait;
#[cfg(not(windows))]
mod services;
mod startup;
+243
View File
@@ -0,0 +1,243 @@
//! #3289: a native frontend may service the event loop while a script waits for rendering.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use effectcraft_engine::project::render_queue::OutputFormat;
use effectcraft_engine::{ExportJob, ExportResult, Exporter, Session};
use serde_json::{Value, json};
/// A slow exporter without expensive frames. A bounded wait makes the old blocking path
/// fail the assertions instead of hanging the test process.
struct Gate {
entered: AtomicUsize,
permits: AtomicUsize,
failed: bool,
}
impl Exporter for Gate {
fn formats(&self) -> Vec<OutputFormat> {
vec![OutputFormat::H264]
}
fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
let call = self.entered.fetch_add(1, Ordering::SeqCst) + 1;
let end = Instant::now() + Duration::from_secs(2);
while self.permits.load(Ordering::SeqCst) < call {
if !progress(0, 1) {
return Err(effectcraft_engine::render_queue::CANCELLED.into());
}
if Instant::now() > end {
return Err("test exporter timed out waiting for the frontend".into());
}
std::thread::sleep(Duration::from_millis(1));
}
if self.failed { Err("test encoder failure".into()) } else { Ok(ExportResult { path: job.path.into(), frames: 1, ..Default::default() }) }
}
}
fn session(permits: usize, failed: bool) -> (Session, Arc<Gate>) {
let gate = Arc::new(Gate { entered: AtomicUsize::new(0), permits: AtomicUsize::new(permits), failed });
let mut s = super::session();
s.exporter = Some(gate.clone());
s.execute("comp.new", json!({"name": "Render", "width": 16, "height": 16, "duration": 1})).unwrap();
let path = std::env::temp_dir().join(format!("ec-script-render-{}.mp4", std::process::id()));
s.execute("renderQueue.add", json!({"output": path.to_string_lossy()})).unwrap();
s.prefs.auto_save.enabled = false;
(s, gate)
}
fn finish(s: &mut Session) -> Value {
let end = Instant::now() + Duration::from_secs(3);
loop {
if let Some(result) = crate::ui::poll_render_wait(s) {
return result;
}
assert!(Instant::now() < end, "script did not resume");
std::thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn render_yields_without_finishing_the_script_or_its_undo_group() {
let (mut s, gate) = session(0, false);
s.script_ui.yield_renders = true;
let before = s.history.undo.len();
let out = crate::run_code(
&mut s,
"app.beginUndoGroup('Render transaction'); app.project.activeItem.layers.addNull().name = 'Before'; $.writeln('before'); app.project.renderQueue.render(); app.project.activeItem.layers.addNull().name = 'After'; $.writeln('after'); app.endUndoGroup(); [42, app.project.renderQueue.rendering];",
"render.jsx",
);
assert!(out.error.is_none(), "{out:?}");
assert!(out.render_host.is_some(), "blocking render returned instead of yielding: {out:?}");
assert!(!out.waiting, "a render suspension is not a modal dialog");
assert!(out.output.is_empty(), "output belongs to the eventual reply");
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "the continuation has not run");
assert!(crate::ui::poll_render_wait(&mut s).is_none());
assert!(!s.undo(), "undo cannot discard the suspended script's live edits");
assert!(s.execute("engine.batch", json!({"steps": [{"command": "layer.newNull"}]})).is_err());
assert!(s.execute("jobs.list", json!({})).is_err(), "status polling must not apply unrelated jobs");
// The frontend resets the invocation-only opt-in after script.run returns.
s.script_ui.yield_renders = false;
gate.permits.store(1, Ordering::SeqCst);
let done = finish(&mut s);
assert_eq!(done["ok"], true, "{done}");
assert_eq!(done["result"], json!([42, false]));
assert_eq!(done["output"], "before\nafter");
assert_eq!(s.project.render_queue[0].status.label(), "Done");
assert_eq!(s.history.undo.len(), before + 1, "one undo group spans the render");
assert_eq!(s.history.undo.last().unwrap().0, "Render transaction");
assert!(s.script_ui.render_host.is_none());
s.undo();
assert!(s.active_comp().unwrap().layers.is_empty());
}
#[test]
fn a_second_render_keeps_the_same_script_pending() {
let (mut s, gate) = session(0, false);
s.script_ui.yield_renders = true;
let out = crate::run_code(
&mut s,
"app.project.renderQueue.render(); app.project.renderQueue.item(1).render = true; app.project.renderQueue.render(); 17;",
"twice.jsx",
);
let host = out.render_host.unwrap();
s.script_ui.yield_renders = false;
gate.permits.store(1, Ordering::SeqCst);
let end = Instant::now() + Duration::from_secs(3);
while gate.entered.load(Ordering::SeqCst) < 2 {
assert!(crate::ui::poll_render_wait(&mut s).is_none(), "no premature reply after the first render");
assert!(Instant::now() < end);
std::thread::sleep(Duration::from_millis(1));
}
assert_eq!(s.script_ui.render_host, Some(host));
gate.permits.store(2, Ordering::SeqCst);
assert_eq!(finish(&mut s)["result"], 17);
}
#[test]
fn cancellation_and_encoder_failure_resume_with_terminal_statuses() {
for failed in [false, true] {
let (mut s, gate) = session(0, failed);
s.script_ui.yield_renders = true;
let out = crate::run_code(&mut s, "app.project.renderQueue.render(); [9, app.project.renderQueue.rendering];", "stopped.jsx");
assert!(out.render_host.is_some());
if failed {
gate.permits.store(1, Ordering::SeqCst);
} else {
s.stop_render();
}
let done = finish(&mut s);
assert_eq!(done["ok"], true, "the synchronous command reports status, not a thrown script error: {done}");
assert_eq!(done["result"], json!([9, false]));
assert_ne!(s.project.render_queue[0].status.label(), "Done");
}
}
#[test]
fn headless_scripts_still_wait_and_start_errors_do_not_suspend() {
let (mut s, _) = session(1, false);
let out = crate::run_code(&mut s, "app.project.renderQueue.render(); [7, app.project.renderQueue.rendering];", "headless.jsx");
assert!(out.error.is_none(), "{out:?}");
assert_eq!(out.result, json!([7, false]));
assert!(out.render_host.is_none());
assert_eq!(s.project.render_queue[0].status.label(), "Done");
s.script_ui.yield_renders = true;
let out = crate::run_code(&mut s, "app.project.renderQueue.render();", "empty.jsx");
assert!(out.error.is_some());
assert!(out.render_host.is_none());
assert!(s.script_ui.render_host.is_none());
}
#[test]
fn retained_palette_callbacks_can_yield_after_the_original_control_invocation() {
let (mut s, gate) = session(0, false);
s.script_ui.yield_renders = true;
let out = crate::run_code(
&mut s,
"var w = new Window('palette', 'Render'); var b = w.add('button', undefined, 'Go', {name:'go'}); b.onClick = function () { app.project.renderQueue.render(); $.writeln('resumed'); }; w.show();",
"palette-render.jsx",
);
assert!(out.error.is_none(), "{out:?}");
s.script_ui.yield_renders = false;
let result = s.execute("scriptui.click", json!({"widget": "go"})).unwrap();
assert!(result["renderHost"].is_number(), "{result}");
gate.permits.store(1, Ordering::SeqCst);
assert_eq!(finish(&mut s)["output"], "resumed");
let window = s.script_ui.windows[0].id;
s.execute("scriptui.close", json!({"window": window})).unwrap();
}
#[test]
fn post_render_actions_complete_before_the_script_resumes() {
use effectcraft_engine::project::{Footage, FootageKind};
struct Probe;
impl effectcraft_engine::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() })
}
}
for action in ["import", "setProxy"] {
let (mut s, gate) = session(0, false);
s.importer = Some(Arc::new(Probe));
let id = s.project.render_queue[0].id;
s.execute("renderQueue.setOutputModule", json!({"item": id, "postRenderAction": action})).unwrap();
let before = s.project.items.len();
let comp = s.active_comp_id().unwrap();
s.script_ui.yield_renders = true;
let out = crate::run_code(&mut s, "app.project.renderQueue.render(); 29;", "post-render.jsx");
assert!(out.render_host.is_some(), "{out:?}");
gate.permits.store(1, Ordering::SeqCst);
// A query must not consume updates while the edit guard is still active.
let end = Instant::now() + Duration::from_secs(3);
while s.is_rendering() {
assert!(Instant::now() < end);
std::thread::sleep(Duration::from_millis(1));
}
s.execute("renderQueue.list", json!({})).unwrap();
assert_eq!(s.project.items.len(), before);
assert_eq!(finish(&mut s)["result"], 29);
if action == "import" {
assert_eq!(s.project.items.len(), before + 1);
} else {
assert!(s.project.item(comp).unwrap().proxy.is_some());
}
}
}
#[test]
fn deferred_errors_keep_their_included_source_location() {
let (mut s, gate) = session(0, false);
let dir = std::env::temp_dir().join(format!("ec-render-include-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("render.jsx"), "app.project.renderQueue.render();\nmissingAfterRender();").unwrap();
std::fs::write(dir.join("main.jsx"), "#include 'render.jsx'").unwrap();
s.script_ui.yield_renders = true;
let out = crate::run_code(&mut s, "#include 'render.jsx'", &dir.join("main.jsx").to_string_lossy());
assert!(out.render_host.is_some(), "{out:?}");
gate.permits.store(1, Ordering::SeqCst);
let done = finish(&mut s);
assert_eq!(done["ok"], false);
assert_eq!(done["error"]["line"], 2);
assert!(done["error"]["file"].as_str().unwrap().ends_with("render.jsx"), "{done}");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn included_error_mapping_survives_a_modal_yield_after_rendering() {
let (mut s, gate) = session(0, false);
let dir = std::env::temp_dir().join(format!("ec-render-modal-include-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("render.jsx"), "app.project.renderQueue.render();\nvar d = new Window('dialog', 'Continue'); d.add('button', undefined, 'OK', {name:'ok'}); d.show();\nmissingAfterModal();").unwrap();
std::fs::write(dir.join("main.jsx"), "#include 'render.jsx'").unwrap();
s.script_ui.yield_renders = true;
let out = crate::run_code(&mut s, "#include 'render.jsx'", &dir.join("main.jsx").to_string_lossy());
assert!(out.render_host.is_some(), "{out:?}");
gate.permits.store(1, Ordering::SeqCst);
assert_eq!(finish(&mut s)["waiting"], true);
let done = s.execute("scriptui.click", json!({"widget": "ok"})).unwrap();
assert_eq!(done["ok"], false);
assert_eq!(done["error"]["line"], 3);
assert!(done["error"]["file"].as_str().unwrap().ends_with("render.jsx"), "{done}");
std::fs::remove_dir_all(dir).unwrap();
}
+91 -8
View File
@@ -3,7 +3,8 @@
//!
//! * **Script files** (native builds) run on a *script-host thread*. The caller's session moves
//! to that thread while JavaScript runs and comes back whenever the script stops: at the end,
//! or while a modal dialog waits in `show()`. Each [`ScriptUiEvent`] moves the session there
//! while a modal dialog waits in `show()`, or while an opted-in frontend waits for rendering.
//! Each [`ScriptUiEvent`] moves the session there
//! again, runs the handler, and brings it back with the windows' new state. The host ends
//! when its last window closes. Only one side holds the session at a time, so nothing runs
//! concurrently and every edit is still an ordinary undoable engine command.
@@ -137,6 +138,8 @@ pub(crate) enum ToHost {
/// script-host threads).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
Tick(Session, Instant),
/// Resume the suspended native render call, never a callback or scheduled task.
ResumeRender(Session),
}
impl ToHost {
@@ -145,7 +148,7 @@ impl ToHost {
match self {
#[cfg(not(target_arch = "wasm32"))]
ToHost::Start(s) => s,
ToHost::Event(s, _) | ToHost::Tick(s, _) => s,
ToHost::Event(s, _) | ToHost::Tick(s, _) | ToHost::ResumeRender(s) => s,
}
}
}
@@ -159,6 +162,7 @@ pub(crate) struct FromHost {
done: bool,
/// When its next scheduled task is due.
next_due: Option<Instant>,
render_wait: bool,
}
/// The host thread's end of the hand-off.
@@ -177,6 +181,8 @@ struct HostEnd {
/// [`effectcraft_engine::scriptui::ScriptUi::project_epoch`] when the host last ran.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
epoch: u64,
/// Retained across render yields so a later error still names its included source file.
source_map: Option<crate::include::Expanded>,
}
fn hosts() -> &'static std::sync::Mutex<BTreeMap<u32, HostEnd>> {
@@ -195,6 +201,7 @@ enum Wake {
Event(ScriptUiEvent),
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
Tick(Instant),
Render,
}
/// On the host thread: hand the session (with the windows' state and what the script printed)
@@ -206,18 +213,20 @@ fn yield_wait(outcome: crate::Outcome, windows: Vec<ScriptWindow>, done: bool) -
match yield_wake(out, windows.clone(), done, None)? {
Wake::Event(ev) => return Some(ev),
Wake::Tick(_) => out = crate::Outcome { waiting: true, ..Default::default() },
Wake::Render => return None,
}
}
}
/// [`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 render_wait = outcome.render_host.is_some();
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, next_due }).is_err() {
if link.tx.send(FromHost { session, outcome, windows, name, done, next_due, render_wait }).is_err() {
return None;
}
if done {
@@ -226,6 +235,7 @@ fn yield_wake(outcome: crate::Outcome, windows: Vec<ScriptWindow>, done: bool, n
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)),
Ok(ToHost::ResumeRender(s)) => (s, Wake::Render),
_ => return None,
};
ACTIVE.with(|a| {
@@ -269,8 +279,9 @@ fn host_main(host: u32, name: String, code: String, link: Link) {
}
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 };
out = crate::Outcome { result: json!(ran), output: settle(), error, ..Default::default() };
}
Some(Wake::Render) => break,
None => break,
}
}
@@ -283,15 +294,30 @@ fn host_main(host: u32, 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, next_due: None });
let _ = link.tx.send(FromHost {
session: std::mem::take(&mut a.session),
outcome: out,
windows: vec![],
name,
done: true,
next_due: None,
render_wait: false,
});
}
}
/// 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, next_due }) => {
Ok(FromHost { session: s, mut outcome, windows, name, done, next_due, render_wait }) => {
*session = s;
session.script_ui.render_host = render_wait.then_some(host);
if !render_wait
&& let Some(map) = &end.source_map
&& let Some(error) = &mut outcome.error
{
map.locate(error);
}
publish(session, host, &name, windows);
if done {
session.script_ui.windows.retain(|w| w.host != host);
@@ -324,7 +350,61 @@ 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, next_due: None, epoch: 0 })
receive(session, host, HostEnd { tx: to_tx, rx: from_rx, next_due: None, epoch: 0, source_map: None })
}
/// Suspend only the JavaScript stack, not the window's event loop (#3289). Keep the output
/// and open undo groups in `Active`: settling here would split the script's undo transaction.
pub(crate) fn wait_for_render(ctx: &mut Context, mut result: J) -> Result<J, String> {
let host = with_active(|a| Ok(a.host))?;
let windows = snapshot(ctx);
let out = crate::Outcome { render_host: Some(host), ..Default::default() };
if !matches!(yield_wake(out, windows, false, None), Some(Wake::Render)) {
return Err("the frontend stopped waiting for the script's render".into());
}
with_active(|a| {
let list = a.session.execute_for_script("renderQueue.list", json!({})).map_err(|e| e.to_string())?;
let ids: Vec<J> = result["items"].as_array().into_iter().flatten().map(|i| i["id"].clone()).collect();
result["items"] = json!(list["items"].as_array().into_iter().flatten().filter(|i| ids.contains(&i["id"])).collect::<Vec<_>>());
result["rendering"] = list["rendering"].clone();
Ok(result)
})
}
pub(crate) fn keep_render_source_map(host: u32, map: crate::include::Expanded) {
if let Ok(mut h) = hosts().lock()
&& let Some(end) = h.get_mut(&host)
{
end.source_map = Some(map);
}
}
/// Called by the native frontend while its normal editing/callback tick is paused. Polling
/// applies terminal statuses and post-render actions before the suspended JS call returns.
pub(crate) fn poll_render_wait(session: &mut Session) -> Option<J> {
let host = session.script_ui.render_host?;
// Only this completion boundary may apply the render's own import/proxy undo steps.
session.script_ui.render_host = None;
session.poll_render();
if session.is_rendering() {
session.script_ui.render_host = Some(host);
return None;
}
session.script_ui.render_host = None;
let fail = |message: &str| crate::Outcome { error: Some(ScriptError { message: message.into(), ..Default::default() }), ..Default::default() }.to_json();
let Some(end) = hosts().lock().ok().and_then(|mut h| h.remove(&host)) else {
return Some(fail("the script host stopped while rendering"));
};
if end.epoch != session.script_ui.project_epoch {
return Some(fail("the project changed while the script was rendering"));
}
if let Err(std::sync::mpsc::SendError(msg)) = end.tx.send(ToHost::ResumeRender(std::mem::take(session))) {
*session = msg.into_session();
return Some(fail("the script host stopped while rendering"));
}
let out = receive(session, host, end);
// A second render in this invocation keeps the original control reply pending.
out.render_host.is_none().then(|| out.to_json())
}
/// Record the windows a host published (named after the script that made them).
@@ -429,6 +509,9 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr
}
drop(stale);
for host in due {
if session.script_ui.render_host.is_some() {
break;
}
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();
@@ -490,7 +573,7 @@ fn tick_inline_host(session: &mut Session, host: u32, name: &str, now: Instant)
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 }
crate::Outcome { result: json!(ran), output, error, ..Default::default() }
}
/// Forget the session's task hosts that have ended (inline ones: that have no tasks left).