Files
pdfcraft/crates/js/src/xfa_session.rs
T
.CooCoo 0db7d57619 Tests: XFA calculation tests no longer fail when the machine is busy (#4535)
Five XFA tests failed at random on a busy machine. The scripts of an
opening or a change share a 3 s wall-clock budget (a click's share 5 s),
each script has 1 s or 3 s, and the engine has 10 s to start; under load
the tests went past one of them, and the scripts they check were skipped
or did not start.

Session::with_xfa_time_scale (and Automation::with_xfa_time_scale)
multiplies each of these limits for a test; the default stays 1. The
script session counts the engine threads it starts on the calling thread
(xfa_session::engines_started).

The hundreds-of-calculations tests run with the limits raised, and the
engine's test now checks that opening starts one engine and a change one
more. A new test sets the factor to 0 and checks that the budget skips
all 210 calculations and says so. Three more tests that failed the same
way under load run with the limits raised.
2026-10-25 10:02:00 -04:00

242 lines
10 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 no state behind in the engine; what a script changed (up to its
//! error, when it failed) 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};
/// Tests: milliseconds the engine is made to take before it is ready for a script.
#[cfg(test)]
pub(crate) static START_DELAY_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
std::thread_local! {
static ENGINES_STARTED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// How many script engine threads the sessions made on this thread have started, for
/// diagnostics and tests: an event's scripts share one, so opening a form with hundreds of
/// calculations starts one. Per thread, so a caller counting its own events is not disturbed
/// by forms on other threads. Always 0 on wasm, where the scripts run on the caller's thread.
pub fn engines_started() -> u64 {
ENGINES_STARTED.with(std::cell::Cell::get)
}
/// 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. `started` is called once the engine is ready for the script (its context built), so
/// a time limit on the script runs from then, not from when it was asked for.
fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome {
// Tests make the engine slow to be ready, to see that the time isn't the script's.
#[cfg(test)]
let started = &|| {
let ms = START_DELAY_MS.load(std::sync::atomic::Ordering::Relaxed);
if ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(ms));
}
started();
};
if s.formcalc {
// FormCalc has no engine to start: its time (and its own deadline) begins at once.
started();
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) {
started();
return XfaOutcome { error: Some(why), ..Default::default() };
}
crate::xfa::run_here(&s.source, &s.event, doc, s.root, s.limits, started)
}
#[cfg(not(target_arch = "wasm32"))]
mod native {
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::time::{Duration, Instant};
use super::{Script, run_script};
use crate::xfa::{ENGINE_START_LIMIT, XfaDoc, XfaOutcome};
enum Reply {
/// The thread is up.
Ready,
/// The engine is ready for the script just sent: its time starts now.
Started,
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>,
/// How long the engine has to be ready for a script ([`ENGINE_START_LIMIT`] by default).
start_limit: Duration,
}
impl XfaSession {
pub fn new(doc: XfaDoc) -> XfaSession {
XfaSession { doc, worker: None, dead: None, start_limit: ENGINE_START_LIMIT }
}
/// Multiply the time the engine has to be ready for a script by `factor` (a script's
/// own time is [`Script::timeout`]).
pub fn with_time_scale(mut self, factor: u32) -> XfaSession {
self.start_limit = ENGINE_START_LIMIT.saturating_mul(factor);
self
}
/// 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 started = reply.clone();
let o = run_script(*s, &doc, &move || {
let _ = started.send(Reply::Started);
});
let _ = reply.send(Reply::Outcome(Box::new(o)));
}
});
spawned.map_err(|e| format!("the script engine could not start: {e}"))?;
super::ENGINES_STARTED.with(|n| n.set(n.get().saturating_add(1)));
match from.recv_timeout(self.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", self.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` from when the engine is ready for it
/// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready, unless
/// [`XfaSession::with_time_scale`] changed it); 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 start_limit = self.start_limit;
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());
}
// The engine's start, then the script's own time.
let (mut running, mut limit) = (false, start_limit);
let mut deadline = Instant::now().checked_add(limit);
loop {
let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now()));
match worker.from.recv_timeout(left) {
Ok(Reply::Outcome(o)) => return *o,
Ok(Reply::Started) => {
// A second call changes nothing: the script's time has already begun.
if !running {
(running, limit) = (true, timeout);
deadline = Instant::now().checked_add(limit);
}
}
// Not expected here: wait on for the outcome.
Ok(Reply::Ready) => continue,
Err(RecvTimeoutError::Timeout) => {
let why = if running {
format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())
} else {
format!("the script engine took longer than {:.0} s to start", start_limit.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 }
}
/// No engine to start on wasm: nothing to scale.
pub fn with_time_scale(self, _factor: u32) -> XfaSession {
self
}
pub fn dead(&self) -> Option<&str> {
None
}
pub fn run(&mut self, script: Script) -> XfaOutcome {
run_script(script, &self.doc, &|| {})
}
}
}
pub use native::XfaSession;