M12: XFA script time limits start when the engine is ready (#1081)

* M12: XFA script time limits start when the engine is ready

An XFA script's wall-clock limit (1 s for scripts that run unasked, such
as initialize and calculate) also counted the engine's start-up: the
script thread, a new boa Context, and for the first script in a process
the engine's own one-time set-up (~60-170 ms when idle, shared by every
script starting at the same moment). On a busy machine a trivial
initialize script could pass 1 s, be abandoned, and so turn all of the
form's scripts off, with the error only in the JavaScript console.

That made two ui-egui xfa.rs tests flaky
(xfa_buttons_run_their_scripts_in_the_app,
messages_from_scripts_run_on_open_show_right_away): under full CPU load
25 of 40 runs of the test binary failed, and none of 40 with this
change.

- run_within: the script calls `started` once the engine is ready, and
  the time limit runs from then. Start-up has a cap of its own (10 s),
  so a caller still never waits forever.
- run_here: a once-per-process warm-up script takes the engine's first
  use, so the first form script's timed part drops from ~52 ms to under
  ~11 ms.
- FormCalc (no engine to start) signals at once, as before.
- Tests: a slow start doesn't abandon a script; the script's own time,
  and start-up, are still capped. The UI tests print the script console
  when they fail.

Runaway scripts are still abandoned at the same limits
(runaway_xfa_calculations_at_open_end_quickly_with_a_report).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add arcanii to the contributor names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Bryan Mark 2026-10-12 00:58:28 +09:00 committed by GitHub
parent b752827162
commit a7e24b9512
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 124 additions and 27 deletions

View File

@ -136,6 +136,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Newest first: the date, what moved, and the new overall estimate.
- **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged.
- **2026-10-11 (M12, XFA script time limits):** An XFA script's time limit (1 s for scripts that run unasked, on open and on every change) counted the engine's start-up too: the script's thread, a new context and, for the first script in a process, the engine's own set-up (≈ 60–170 ms when idle). On a busy machine a trivial initialize script could pass the limit, be abandoned and turn the form's scripts off; that made `xfa_buttons_run_their_scripts_in_the_app` and `messages_from_scripts_run_on_open_show_right_away` flaky (under full CPU load 25 of 40 runs of the test binary failed, with "ran longer than 1.0 s and was abandoned" in the console). The limit now runs from when the engine is ready; start-up has its own 10 s cap, so nothing waits forever, and a trivial warm-up script takes the first-in-process set-up. Under the same load none of 40 runs fails, and runaway scripts are abandoned as before. The two UI tests print the script console when they fail. Overall unchanged.
- **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %.
- **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged.

View File

@ -74,3 +74,8 @@ sync_github_name = true
[people.jay-tau]
sync_github_name = true
[people.arcanii]
real_name = "Bryan Mark"
display_name = "Arcanii"
sync_github_name = false

View File

@ -2543,7 +2543,11 @@ pub(crate) fn run_formcalc_at(
/// [`run_formcalc`] on its own thread, abandoned after `timeout` (and told to stop itself then).
pub fn run_formcalc_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome {
let (script, event, doc) = (script.to_string(), event.clone(), doc.clone());
run_within("pdfcraft-formcalc", timeout, move || run_formcalc_at(&script, &event, &doc, root, limits, Some(timeout)))
// FormCalc has no engine to start: its time (and its own deadline) begins at once.
run_within("pdfcraft-formcalc", timeout, move |started| {
started();
run_formcalc_at(&script, &event, &doc, root, limits, Some(timeout))
})
}
#[cfg(test)]

View File

@ -544,6 +544,37 @@ mod xfa_model {
let o = go("xfa.host.messageBox(new Array(100000).join('x'));", "form1[0].page1[0]", "click");
assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097));
}
#[test]
fn a_slow_engine_start_does_not_count_against_the_script() {
let ms = std::time::Duration::from_millis;
// On a busy machine the engine can take longer to start than a script may run: the
// script still runs (abandoning it would turn the form's scripts off).
let o = run_within_limits("test", ms(10_000), ms(500), move |started| {
std::thread::sleep(ms(1_500));
started();
XfaOutcome { result: Some("ran".into()), ..Default::default() }
});
assert!(!o.abandoned, "{o:?}");
assert_eq!(o.result.as_deref(), Some("ran"));
// The script's own time counts from then.
let o = run_within_limits("test", ms(10_000), ms(200), move |started| {
started();
std::thread::sleep(ms(2_000));
XfaOutcome::default()
});
assert!(o.abandoned && o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{o:?}");
// Start-up has a limit of its own, so nobody waits forever for an engine.
let o = run_within_limits("test", ms(200), ms(10_000), move |started| {
std::thread::sleep(ms(2_000));
started();
XfaOutcome::default()
});
assert!(o.abandoned && o.error.as_deref().is_some_and(|e| e.contains("to start")), "{o:?}");
// A script that finishes without saying it started (refused at once) isn't abandoned.
let o = run_within_limits("test", ms(10_000), ms(200), |_| XfaOutcome { error: Some("refused".into()), ..Default::default() });
assert!(!o.abandoned && o.error.as_deref() == Some("refused"), "{o:?}");
}
}
mod formcalc {

View File

@ -1345,7 +1345,7 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim
let spawned = std::thread::Builder::new()
.name("pdfcraft-xfa-js".into())
.stack_size(crate::SCRIPT_STACK)
.spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits));
.spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits, &|| {}));
match spawned {
Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())),
Err(e) => failed(format!("the script engine could not start: {e}")),
@ -1353,56 +1353,112 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim
})
}
#[cfg(target_arch = "wasm32")]
run_here(script, event, doc, root.clone(), limits)
run_here(script, event, doc, root.clone(), limits, &|| {})
}
/// [`run_xfa`] with a time limit: a script still running after `timeout` (loops inside
/// nested function calls can run for hours within the engine's per-frame loop limit) is left
/// on its thread, which ends when the engine's own limits stop it, and an outcome with
/// `abandoned` set comes back at once. In the browser build there are no threads: the script
/// runs to its limits.
/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready
/// (loops inside nested function calls can run for hours within the engine's per-frame loop
/// limit) is left on its thread, which ends when the engine's own limits stop it, and an
/// outcome with `abandoned` set comes back at once. In the browser build there are no
/// threads: the script runs to its limits.
pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome {
if let Some(why) = refuse(script) {
return XfaOutcome { error: Some(why), ..Default::default() };
}
let (script, event, doc) = (script.to_string(), event.clone(), doc.clone());
run_within("pdfcraft-xfa-js", timeout, move || run_here(&script, &event, &doc, root, limits))
run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started))
}
/// Longest the script engine may take to be ready for a script (its thread and context, and
/// for the first script in a process the engine's own set-up) before the script is abandoned.
/// It doesn't count against the script's time: on a busy machine the start alone can take
/// longer than the tightest limit, and abandoning a script turns the form's scripts off.
const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10);
/// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its
/// outcome; past that the thread is abandoned and the outcome says so. On wasm there are no
/// threads: the script runs here.
pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce() -> XfaOutcome + Send + 'static) -> XfaOutcome {
/// outcome, counted from when it calls `started` (once the engine is ready); past that the
/// thread is abandoned and the outcome says so. On wasm there are no threads: the script runs
/// here.
pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static) -> XfaOutcome {
run_within_limits(name, ENGINE_START_LIMIT, timeout, script)
}
/// [`run_within`] with the engine's start-up limit given.
pub(crate) fn run_within_limits(
name: &str,
start_limit: std::time::Duration,
timeout: std::time::Duration,
script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static,
) -> XfaOutcome {
#[cfg(not(target_arch = "wasm32"))]
{
use std::sync::mpsc::RecvTimeoutError;
use std::time::Instant;
enum Step {
Started,
Done(XfaOutcome),
}
let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() };
let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() };
let (tx, rx) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name(name.into()).stack_size(crate::SCRIPT_STACK).spawn(move || {
// The receiver is gone when the caller stopped waiting: nothing to report then.
let _ = tx.send(script());
let started = tx.clone();
let outcome = script(&move || {
let _ = started.send(Step::Started);
});
let _ = tx.send(Step::Done(outcome));
});
if let Err(e) = spawned {
return failed(format!("the script engine could not start: {e}"));
}
match rx.recv_timeout(timeout) {
Ok(o) => o,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => XfaOutcome {
error: Some(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())),
abandoned: true,
..Default::default()
},
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => failed("the script stopped with an internal error".into()),
// Wait for the engine within its start-up limit, then for the script within its own.
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 rx.recv_timeout(left) {
Ok(Step::Done(o)) => return o,
Ok(Step::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);
}
}
Err(RecvTimeoutError::Timeout) if running => {
return abandoned(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64()));
}
Err(RecvTimeoutError::Timeout) => {
return abandoned(format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64()));
}
Err(RecvTimeoutError::Disconnected) => return failed("the script stopped with an internal error".into()),
}
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = (name, timeout);
script()
let _ = (name, start_limit, timeout);
script(&|| {})
}
}
fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits) -> XfaOutcome {
/// The first script in a process sets the engine up (its built-ins, parser and compiler come
/// into use): a trivial script takes that on, so a form's script is never timed with it.
fn warm_up() {
static WARM: std::sync::Once = std::sync::Once::new();
// `call_once_force`: a poisoned `Once` (a panic while warming up) only means warming up again.
WARM.call_once_force(|_| {
let mut ctx = Context::default();
let _ = ctx.eval(Source::from_bytes(b"(function () { return eval('0'); }).call({})"));
});
}
/// `started` is called once the engine is ready: a time limit on the script runs from then.
fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome {
warm_up();
let mut ctx = Context::default();
started();
ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations);
ctx.runtime_limits_mut().set_recursion_limit(limits.recursion);
let host_state = XHost::new(root, &event.target, doc);

View File

@ -135,10 +135,10 @@ fn xfa_buttons_run_their_scripts_in_the_app() {
};
assert!(!names(&h).iter().any(|n| n == "amount_2"));
click_field(&mut h, "addRow");
assert!(names(&h).iter().any(|n| n == "amount_2"), "{:?}", names(&h));
assert!(names(&h).iter().any(|n| n == "amount_2"), "{:?}; script console: {:?}", names(&h), h.state().js_console.log);
// The message box shows as a notice.
click_field(&mut h, "hello");
h.get_by_label_contains("Hello 2");
assert!(h.query_by_label_contains("Hello 2").is_some(), "script console: {:?}", h.state().js_console.log);
}
#[test]
@ -147,7 +147,7 @@ fn messages_from_scripts_run_on_open_show_right_away() {
.replace("if (qty.rawValue === null) qty.rawValue = 2;", r#"xfa.host.messageBox("Welcome to the form"); xfa.host.print();"#);
let mut h = open(pdfcraft_xfa::fixtures::shell(&tpl));
h.run_steps(2);
h.get_by_label_contains("Welcome to the form");
assert!(h.query_by_label_contains("Welcome to the form").is_some(), "script console: {:?}", h.state().js_console.log);
assert_eq!(h.state().dialog, None, "an initialize script can't open the Print dialog");
let id = h.state().views[0].id;
assert!(h.state_mut().session.take_js_output(id).is_empty(), "nothing waits for the next edit");