Files
pdfcraft/crates/js/src/xfa_session.rs
T
ysrdevs 4b45fc9cb4 XFA: one script thread per event, and the live form kept up to date in place (#1355)
* XFA: one script thread per event, and the live form kept up to date in place

A form with hundreds of calculate scripts used to spend its open-time
budget on set-up: every script got a thread of its own, and the engine
dropped its live form after every value a script settled, rebuilding
the whole tree for the next script.

The js crate gains xfa_session::XfaSession: the scripts of one event
run on one thread (with the script stack), each in a fresh JavaScript
context or FormCalc interpreter on a copy of the form as the engine
holds it then, so scripts share nothing, a script that fails halfway
leaves nothing behind, and what a script changed reaches the next one
only through the engine. A script past its time limit is abandoned on
the thread and the session is dead from then on; a fault in the engine
(a panic) loses only that script, the thread is replaced. On wasm the
scripts run inline, as before.

The engine's runner keeps one session per event and, for a value, now
refreshes the one field in its live form in place (xfa::refresh_value,
computing the value as the tree builder would: saved values, template
defaults) instead of rebuilding the tree; rows, presence, access,
resets, relayouts and data rewrites still rebuild it. Under test, the
live form is compared with a fresh build before every script and at
the end of every event, and a difference ends the test binary (a panic
would be caught by the engine's guard and turned into a script error).

210 calculate scripts (one per row of 10 sections x 20 rows, plus a sum
per section), debug build, instrumented inside the engine: the script
event on open 495 ms -> 204 ms (of which live-form rebuilds 217 ms ->
0), on a change 300 ms -> 172 ms; through the tools, layout included,
open 1.27 s -> 0.97 s and a change 0.44 s -> 0.22 s. The rebuild cost
removed grew with form size times script count.

* CI: the GoToR tests' case lists are only grown on Unix, so Windows must not warn on their mut
2026-10-12 05:39:49 -04:00

174 lines
7.2 KiB
Rust

//! One script engine for a whole form event. Every script of the event runs on one thread
//! (with the script stack), each in a fresh JavaScript context on a fresh copy of the form as
//! the caller holds it, so scripts can't see each other's variables or objects and a script
//! that failed halfway leaves nothing behind; what a script changed reaches the next one only
//! through the caller, which applies the effects and sends the form as it then is. Before,
//! every script also got a thread of its own and the caller rebuilt the form for each: a form
//! with hundreds of calculations spent its open-time budget on that.
//!
//! A script that runs past its time limit is abandoned on the thread and the session is dead
//! from then on. A script that fails from a fault in the engine (a panic) loses only itself:
//! the thread is replaced for the next script. On wasm there are no threads: scripts run here.
use crate::Limits;
use crate::xfa::{XfaDoc, XfaEvent, XfaNode, XfaOutcome};
/// What one script run needs.
#[derive(Clone, Debug)]
pub struct Script {
pub source: String,
pub formcalc: bool,
pub event: XfaEvent,
/// The form as the caller holds it now.
pub root: XfaNode,
pub limits: Limits,
pub timeout: std::time::Duration,
}
/// Run `s` here: a fresh context (JavaScript) or interpreter (FormCalc) on its copy of the form.
fn run_script(s: Script, doc: &XfaDoc) -> XfaOutcome {
if s.formcalc {
return crate::formcalc::run_formcalc_at(&s.source, &s.event, doc, s.root, s.limits, Some(s.timeout));
}
if let Some(why) = crate::refuse(&s.source) {
return XfaOutcome { error: Some(why), ..Default::default() };
}
crate::xfa::run_here(&s.source, &s.event, doc, s.root, s.limits, &|| {})
}
#[cfg(not(target_arch = "wasm32"))]
mod native {
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::time::Instant;
use super::{Script, run_script};
use crate::xfa::{ENGINE_START_LIMIT, XfaDoc, XfaOutcome};
enum Reply {
Ready,
Outcome(Box<XfaOutcome>),
}
/// The engine's thread, started for the first script and kept for the event.
struct Worker {
to: Sender<Box<Script>>,
from: Receiver<Reply>,
}
/// The scripts of one event, run on one thread.
pub struct XfaSession {
doc: XfaDoc,
worker: Option<Worker>,
/// A script ran past its limit: its thread is left to it and nothing more runs.
dead: Option<String>,
}
impl XfaSession {
pub fn new(doc: XfaDoc) -> XfaSession {
XfaSession { doc, worker: None, dead: None }
}
/// Why nothing more runs, once a script was abandoned.
pub fn dead(&self) -> Option<&str> {
self.dead.as_deref()
}
/// The thread, started now when there is none (the first script, or after one failed
/// with a fault in the engine). `Err`: it could not start.
fn worker(&mut self) -> Result<&mut Worker, String> {
if self.worker.is_none() {
let (to, inbox) = channel::<Box<Script>>();
let (reply, from) = channel::<Reply>();
let doc = self.doc.clone();
let spawned = std::thread::Builder::new().name("pdfcraft-xfa-session".into()).stack_size(crate::SCRIPT_STACK).spawn(move || {
crate::xfa::warm_up();
// The caller is gone when a send fails: nothing to report then.
let _ = reply.send(Reply::Ready);
while let Ok(s) = inbox.recv() {
let o = run_script(*s, &doc);
let _ = reply.send(Reply::Outcome(Box::new(o)));
}
});
spawned.map_err(|e| format!("the script engine could not start: {e}"))?;
match from.recv_timeout(ENGINE_START_LIMIT) {
Ok(Reply::Ready) => {}
Ok(_) => return Err("the script engine answered out of turn".into()),
Err(RecvTimeoutError::Timeout) => {
return Err(format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64()));
}
Err(RecvTimeoutError::Disconnected) => return Err("the script engine stopped with an internal error".into()),
}
self.worker = Some(Worker { to, from });
}
self.worker.as_mut().ok_or_else(|| "the script engine is not running".into())
}
/// Run one script, waiting at most its `timeout`; past that it is abandoned on its
/// thread and the session is dead.
pub fn run(&mut self, script: Script) -> XfaOutcome {
let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() };
let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() };
if let Some(why) = &self.dead {
return abandoned(why.clone());
}
let timeout = script.timeout;
let worker = match self.worker() {
Ok(w) => w,
Err(why) => return failed(why),
};
if worker.to.send(Box::new(script)).is_err() {
// The thread is gone (a fault in the engine): the next script gets a new one.
self.worker = None;
return failed("the script engine stopped with an internal error".into());
}
let deadline = Instant::now().checked_add(timeout);
loop {
let left = deadline.map_or(timeout, |d| d.saturating_duration_since(Instant::now()));
match worker.from.recv_timeout(left) {
Ok(Reply::Outcome(o)) => return *o,
// Not expected here: wait on for the outcome.
Ok(Reply::Ready) => continue,
Err(RecvTimeoutError::Timeout) => {
let why = format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64());
self.dead = Some(why.clone());
// Dropping the sender ends the thread's loop once the script is done.
self.worker = None;
return abandoned(why);
}
Err(RecvTimeoutError::Disconnected) => {
self.worker = None;
return failed("the script stopped with an internal error".into());
}
}
}
}
}
}
#[cfg(target_arch = "wasm32")]
mod native {
use super::{Script, run_script};
use crate::xfa::{XfaDoc, XfaOutcome};
/// The scripts of one event, run here (no threads on wasm: a script's own limits bound it).
pub struct XfaSession {
doc: XfaDoc,
}
impl XfaSession {
pub fn new(doc: XfaDoc) -> XfaSession {
XfaSession { doc }
}
pub fn dead(&self) -> Option<&str> {
None
}
pub fn run(&mut self, script: Script) -> XfaOutcome {
run_script(script, &self.doc)
}
}
}
pub use native::XfaSession;