From f56a146b220f7e970b0e61ed21eb5f3fcf30bf90 Mon Sep 17 00:00:00 2001
From: FredApps <153173854+FredApps@users.noreply.github.com>
Date: Wed, 11 Nov 2026 10:39:13 +0100
Subject: [PATCH] feat(js): timers keep and fire in the document's script realm
(#169)
Ported from #4751 by @pyatsysh onto #5699's per-document realm.
`app.setInterval` and `app.setTimeOut` (an expression or a function) are
kept by the retained realm and reported in every Outcome (`timers`); the
caller owns the clock and fires one by running a Job with `timer` set, in
the realm that set it. `clearInterval`/`clearTimeOut` forget one. Bounds
kept from #4751: at most 64 timers per document, periods of at least
10 ms. A one-off run keeps none (the functions return an inert object, as
before), and changed document scripts start a realm without the old
timers.
Co-authored-by: Peter Yatsyshin
---
crates/engine/src/js.rs | 4 ++
crates/js/src/globals.rs | 13 ++--
crates/js/src/lib.rs | 2 +
crates/js/src/model.rs | 3 +
crates/js/src/run.rs | 13 +++-
crates/js/src/session.rs | 107 ++++++++++++++++++++++++++++++-
crates/js/src/timers.rs | 132 +++++++++++++++++++++++++++++++++++++++
7 files changed, 261 insertions(+), 13 deletions(-)
create mode 100644 crates/js/src/timers.rs
diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs
index cb4c0caf65..f7bc3cbd5b 100644
--- a/crates/engine/src/js.rs
+++ b/crates/engine/src/js.rs
@@ -660,6 +660,7 @@ impl Scripts for JsRunner {
fields: states,
doc_scripts: self.doc_scripts.clone(),
calculations,
+ timer: None,
};
#[cfg(not(target_arch = "wasm32"))]
let remaining = self.callback_time_left();
@@ -748,6 +749,7 @@ impl Scripts for JsRunner {
fields: states,
doc_scripts: self.doc_scripts.clone(),
calculations: calculations.clone(),
+ timer: None,
};
let remaining = self.callback_time_left();
let outcome = if remaining.is_zero() {
@@ -1028,6 +1030,7 @@ impl Session {
fields: states.clone(),
doc_scripts: runner.doc_scripts,
calculations,
+ timer: None,
},
answers,
);
@@ -1495,6 +1498,7 @@ impl Session {
fields: states.clone(),
doc_scripts: runner.doc_scripts,
calculations: calculation_scripts(cos, &fields),
+ timer: None,
};
let receiver =
doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.start(job).map_err(EditError::Invalid)?;
diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs
index 3dfbf3924e..5d690f3b19 100644
--- a/crates/js/src/globals.rs
+++ b/crates/js/src/globals.rs
@@ -416,11 +416,6 @@ fn js_number(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") }
}
-fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult {
- // Timers never fire in the sandbox; scripts get an object they can pass to clear*.
- Ok(ObjectInitializer::new(ctx).build().into())
-}
-
/// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as
/// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components.
fn color_object(ctx: &mut Context) -> JsObject {
@@ -556,10 +551,10 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
.function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2)
.function(NativeFunction::from_fn_ptr(crate::interaction::popup_menu), js_string!("popUpMenuEx"), 0)
.function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1)
- .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2)
- .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2)
- .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1)
- .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1)
+ .function(NativeFunction::from_fn_ptr(crate::timers::set_time_out), js_string!("setTimeOut"), 2)
+ .function(NativeFunction::from_fn_ptr(crate::timers::set_interval), js_string!("setInterval"), 2)
+ .function(NativeFunction::from_fn_ptr(crate::timers::clear), js_string!("clearTimeOut"), 1)
+ .function(NativeFunction::from_fn_ptr(crate::timers::clear), js_string!("clearInterval"), 1)
.property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY)
.property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY)
.property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY)
diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs
index 6d722c00ae..482e6c8bd0 100644
--- a/crates/js/src/lib.rs
+++ b/crates/js/src/lib.rs
@@ -73,6 +73,8 @@ mod geometry;
pub use geometry::{rotate_rect, unrotate_rect};
mod icons;
+mod timers;
+pub use timers::{MAX_TIMERS, MIN_TIMER_MS, Timer};
pub mod interaction;
pub mod session;
diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs
index 70bb677d8a..e94868d993 100644
--- a/crates/js/src/model.rs
+++ b/crates/js/src/model.rs
@@ -382,6 +382,9 @@ pub struct Outcome {
pub doc_errors: Vec,
/// The script's completion value (what the console prints), unless undefined.
pub result: Option,
+ /// The timers of a document's retained realm as they stand after the run, in the order they
+ /// were set (`app.setInterval`, `app.setTimeOut`); none from a one-off run.
+ pub timers: Vec,
}
/// Bounds that keep a script from hanging the viewer.
diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs
index ececa107a3..3353fe4826 100644
--- a/crates/js/src/run.rs
+++ b/crates/js/src/run.rs
@@ -1,7 +1,9 @@
use super::globals::{event_object, install};
use super::host::{Host, Shared, host, text};
use super::model::{DocInfo, Event, FieldState, Limits, Outcome};
-use super::{Arc, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Realm, RefCell, Source, js_string};
+use super::{
+ Arc, BTreeSet, Context, HostHooks, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Realm, RefCell, Source, js_string,
+};
/// Longest script, in bytes.
pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024;
@@ -148,6 +150,7 @@ pub(crate) fn run_shared_here(
reset_calls: 0,
calculation_evaluations: 0,
calculation_limit_exceeded: false,
+ call: None,
},
limits,
)
@@ -200,6 +203,8 @@ pub(crate) struct ScriptSetup<'a> {
pub(crate) reset_calls: usize,
pub(crate) calculation_evaluations: usize,
pub(crate) calculation_limit_exceeded: bool,
+ /// A timer's function to call in place of the script (#169).
+ pub(crate) call: Option,
}
/// Create a restricted temporary Boa context and convert initialization failures into script outcomes.
@@ -296,7 +301,10 @@ pub(crate) fn run_in_context(
doc_errors.push((i, doc_error(i, &e)));
}
}
- let r = ctx.eval(Source::from_bytes(script))?;
+ let r = match &setup.call {
+ Some(f) => f.call(&JsValue::undefined(), &[], ctx)?,
+ None => ctx.eval(Source::from_bytes(script))?,
+ };
if !r.is_undefined() {
out.result = Some(text(&r, ctx)?);
}
@@ -332,6 +340,7 @@ pub(crate) fn run_in_context(
}
ctx.insert_data(RefCell::new(h));
}
+ out.timers = crate::timers::report(ctx);
out
}
diff --git a/crates/js/src/session.rs b/crates/js/src/session.rs
index 1e01674df1..c92b074adf 100644
--- a/crates/js/src/session.rs
+++ b/crates/js/src/session.rs
@@ -16,6 +16,10 @@ pub struct Job {
pub doc_scripts: Vec,
/// Target positions in this job's field snapshot and their ordered script sources.
pub calculations: Vec<(usize, String)>,
+ /// Fire this timer of the realm (`app.setInterval`, `app.setTimeOut`) in place of `script`
+ /// (#169). A timer the realm no longer has runs nothing; nor does any timer of a realm that
+ /// has not started, or whose document scripts changed (its timers went with it).
+ pub timer: Option,
}
/// Live field instances are independent of restorable PDF object-number allocation.
#[derive(Default)]
@@ -129,6 +133,9 @@ impl Realm {
}
fn run_job(&mut self, job: Job) -> Outcome {
let failed = |why: String| Outcome { rc: true, execution_refused: true, realm_untouched: true, error: Some(why), ..Default::default() };
+ if let Some(id) = job.timer {
+ return self.fire(job, id);
+ }
// A script too long or too deeply nested to run is that script's error, as on `main`: the
// value stays and the edit's other scripts still run. The realm is not touched.
if let Some(why) = crate::refuse(&job.script) {
@@ -147,7 +154,10 @@ impl Realm {
let initialize = self.context.is_none() || self.scripts != job.doc_scripts;
if initialize {
self.context = match crate::Context::builder().host_hooks(std::rc::Rc::new(crate::run::NoCodeFromStrings)).build() {
- Ok(c) => Some(c),
+ Ok(mut c) => {
+ crate::timers::enable(&mut c);
+ Some(c)
+ }
Err(e) => return failed(e.to_string()),
};
self.scripts = job.doc_scripts.clone();
@@ -186,6 +196,7 @@ impl Realm {
reset_calls: 0,
calculation_evaluations: 0,
calculation_limit_exceeded: false,
+ call: None,
},
Limits::default(),
);
@@ -212,6 +223,7 @@ impl Realm {
reset_calls,
calculation_evaluations,
calculation_limit_exceeded: initial.calculation_limit_exceeded,
+ call: None,
},
Limits::default(),
);
@@ -251,6 +263,7 @@ impl Realm {
reset_calls: 0,
calculation_evaluations: 0,
calculation_limit_exceeded: false,
+ call: None,
},
Limits::default(),
)
@@ -258,6 +271,43 @@ impl Realm {
}
}
+impl Realm {
+ /// Run timer `id` that fell due, with the job's form and document: its expression, or the
+ /// function it was given, in the realm that set it (#169).
+ fn fire(&mut self, job: Job, id: u32) -> Outcome {
+ let idle = |timers: Vec| Outcome { rc: true, realm_untouched: true, timers, ..Default::default() };
+ let Some(ctx) = self.context.as_mut().filter(|_| self.scripts == job.doc_scripts) else { return idle(Vec::new()) };
+ let Some(code) = crate::timers::due(ctx, id) else { return idle(crate::timers::report(ctx)) };
+ let (script, call) = match code {
+ crate::timers::Code::Source(source) => {
+ if let Some(why) = crate::refuse(&source) {
+ return Outcome { rc: true, realm_untouched: true, error: Some(why), timers: crate::timers::report(ctx), ..Default::default() };
+ }
+ (source, None)
+ }
+ crate::timers::Code::Function(f) => (String::new(), Some(f)),
+ };
+ crate::run::run_in_context(
+ ctx,
+ &script,
+ &job.event,
+ &job.doc,
+ job.fields,
+ crate::run::ScriptSetup {
+ doc_scripts: &[],
+ skipped: &[],
+ calculations: &job.calculations,
+ initialize: false,
+ reset_calls: 0,
+ calculation_evaluations: 0,
+ calculation_limit_exceeded: false,
+ call,
+ },
+ Limits::default(),
+ )
+ }
+}
+
#[cfg(not(target_arch = "wasm32"))]
static LIVE_WORKERS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(not(target_arch = "wasm32"))]
@@ -577,8 +627,57 @@ mod tests {
fields: Arc::new(vec![FieldState::new("go", crate::FieldType::Button, vec!["old".into()])]),
doc_scripts: vec!["var buffer='', init=1, opened=event.name;var held=getField('go');function digit(v){buffer+=v;return buffer;}".into()],
calculations: Vec::new(),
+ timer: None,
}
}
+ /// A job firing timer `id` of the realm, as the viewer runs one that fell due (#169).
+ fn fire(id: u32) -> Job {
+ Job { script: String::new(), event: Event { kind: "App".into(), ..Event::doc("Timer") }, timer: Some(id), ..job("") }
+ }
+ #[test]
+ /// Timers a script sets stay in the document's realm and fire there: an interval until it is
+ /// cleared, a time-out once; the field writes a timer makes are reported like any script's.
+ fn timers_fire_in_the_document_realm_until_cleared() {
+ let mut s = ScriptSession::default();
+ let set = s.run(
+ job("var n = 0; var t = app.setInterval(function () { n++; getField('go').value = 'frame ' + n; }, 40); app.setTimeOut('n += 100', 0);"),
+ &[],
+ );
+ assert_eq!(set.error, None);
+ assert_eq!(
+ set.timers,
+ [crate::Timer { id: 1, period_ms: 40, repeat: true }, crate::Timer { id: 2, period_ms: crate::MIN_TIMER_MS, repeat: false }]
+ );
+ let first = s.run(fire(1), &[]);
+ assert_eq!(first.error, None);
+ assert_eq!(first.changed.iter().map(|f| f.value.clone()).collect::>(), [vec!["frame 1".to_string()]]);
+ let once = s.run(fire(2), &[]);
+ assert_eq!(once.timers.len(), 1, "a time-out runs once");
+ assert_eq!(s.run(fire(2), &[]).realm_untouched, true, "and is gone after");
+ s.run(fire(1), &[]);
+ assert_eq!(s.run(job("n;"), &[]).result.as_deref(), Some("102"));
+ let cleared = s.run(job("app.clearInterval(t); app.clearInterval({}); n;"), &[]);
+ assert!(cleared.timers.is_empty());
+ assert!(s.run(fire(1), &[]).changed.is_empty(), "a cleared timer runs nothing");
+ }
+ #[test]
+ /// A document keeps at most 64 timers, a timer runs at most every 10 ms, a one-off run keeps
+ /// none, and changed document scripts start a realm without the old timers.
+ fn timers_are_bounded_and_belong_to_one_realm() {
+ let mut s = ScriptSession::default();
+ let many = s.run(
+ job("var made = 0; try { for (var i = 0; i < 70; i++) { app.setInterval('1', -5); made++; } } catch (e) { made + ': ' + e.message; }"),
+ &[],
+ );
+ assert_eq!(many.result.as_deref(), Some("64: a document's scripts can keep at most 64 timers"));
+ assert!(many.timers.iter().all(|t| t.period_ms == crate::MIN_TIMER_MS));
+ let one_off = crate::run("app.setInterval('1', 50); 'set';", &Event::doc("Console"), &DocInfo::default(), &[], &[], Limits::default());
+ assert_eq!(one_off.result.as_deref(), Some("set"));
+ assert!(one_off.timers.is_empty());
+ let changed = Job { doc_scripts: vec!["var other = 1;".into()], ..fire(1) };
+ let gone = s.run(changed, &[]);
+ assert!(gone.timers.is_empty() && gone.changed.is_empty(), "{gone:?}");
+ }
#[test]
/// Retained Field identity, custom properties and keypad buffers persist locally while separate documents stay isolated.
fn document_realm_keeps_keypad_state_and_isolates_documents() {
@@ -980,6 +1079,7 @@ mod reset_budget_tests {
fields: Arc::new(vec![]),
doc_scripts: vec!["for(var i=0;i<64;i++)resetForm();".into()],
calculations: vec![],
+ timer: None,
};
let out = runtime.run(job.clone(), &[]);
assert!(out.reset_limit_exceeded);
@@ -1006,6 +1106,7 @@ mod event_identity_tests {
fields: Arc::new(vec![field]),
doc_scripts: vec![],
calculations: vec![],
+ timer: None,
};
let out = ScriptSession::default().run(job, &[]);
assert_eq!(out.error, None);
@@ -1027,7 +1128,7 @@ mod cache_capacity_tests {
})
.collect(),
);
- let job=Job{script:"for(var i=0;i<4100;i++)getField('f'+i);var held=getField('f4099');held.custom=42;held===getField('f4099') && getField('f4099').custom===42;".into(),event:Event::doc("Console"),doc:Default::default(),fields,doc_scripts:vec![],calculations:vec![]};
+ let job=Job{script:"for(var i=0;i<4100;i++)getField('f'+i);var held=getField('f4099');held.custom=42;held===getField('f4099') && getField('f4099').custom===42;".into(),event:Event::doc("Console"),doc:Default::default(),fields,doc_scripts:vec![],calculations:vec![],timer:None};
let mut session = ScriptSession::default();
let out = session.run(job.clone(), &[]);
assert_eq!(out.error, None);
@@ -1051,6 +1152,7 @@ mod cache_lifecycle_tests {
fields: Arc::new(vec![field.clone()]),
doc_scripts: vec![],
calculations: vec![],
+ timer: None,
};
let mut realm = Realm::new();
assert_eq!(realm.run(job.clone()).error, None);
@@ -1088,6 +1190,7 @@ mod review13_tests {
fields: Arc::new(fields),
doc_scripts: vec![],
calculations: vec![],
+ timer: None,
}
}
/// A retired accessor must not resolve a replacement even if no job observed the deletion.
diff --git a/crates/js/src/timers.rs b/crates/js/src/timers.rs
new file mode 100644
index 0000000000..88c7d65caf
--- /dev/null
+++ b/crates/js/src/timers.rs
@@ -0,0 +1,132 @@
+//! `app.setInterval`, `app.setTimeOut`, `app.clearInterval` and `app.clearTimeOut` in a
+//! document's retained realm (#169, ported from #4751). The realm keeps the timers its scripts
+//! set and reports them in every [`crate::Outcome`]; the caller owns the clock and fires one
+//! by running a [`crate::session::Job`] with its `timer` when it falls due, in the same realm. A
+//! one-off run (a fresh context) keeps none: there the functions return an object and nothing
+//! ever fires, as before.
+
+use std::cell::RefCell;
+use std::collections::BTreeMap;
+
+use crate::host::{arg, error, text};
+use crate::{Context, JsObject, JsResult, JsValue, MAX_SCRIPT_BYTES, ObjectInitializer};
+
+/// Most timers one document's scripts keep at once; setting another is an error.
+pub const MAX_TIMERS: usize = 64;
+/// The shortest period, in milliseconds: a timer asked to run sooner or more often runs at this.
+pub const MIN_TIMER_MS: u32 = 10;
+
+/// A timer a script set, as the realm reports it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Timer {
+ /// The realm's name for it, never reused while the realm lives.
+ pub id: u32,
+ /// Milliseconds between runs (`setInterval`) or before the one run (`setTimeOut`), at least
+ /// [`MIN_TIMER_MS`].
+ pub period_ms: u32,
+ /// `setInterval`: it runs until it is cleared. `setTimeOut`: once.
+ pub repeat: bool,
+}
+
+/// What a timer runs: its expression, or a function the script gave instead.
+#[derive(Clone)]
+pub(crate) enum Code {
+ Source(String),
+ Function(JsObject),
+}
+
+struct Entry {
+ /// The object `set*` returned, which `clear*` is given back.
+ handle: JsObject,
+ code: Code,
+ period_ms: u32,
+ repeat: bool,
+}
+
+/// A realm's timers by id, in the order they were set (ids only grow).
+#[derive(Default)]
+pub(crate) struct Timers {
+ last: u32,
+ table: BTreeMap,
+}
+
+type Shared = RefCell;
+
+/// Let the timers of a retained realm run: the functions keep what they are given from now on.
+pub(crate) fn enable(ctx: &mut Context) {
+ if ctx.get_data::().is_none() {
+ ctx.insert_data(RefCell::new(Timers::default()));
+ }
+}
+
+/// The realm's timers as they stand, in the order they were set; none in a one-off run.
+pub(crate) fn report(ctx: &Context) -> Vec {
+ ctx.get_data::()
+ .map(|t| t.borrow().table.iter().map(|(id, e)| Timer { id: *id, period_ms: e.period_ms, repeat: e.repeat }).collect())
+ .unwrap_or_default()
+}
+
+/// What timer `id` runs now that it fell due; a `setTimeOut` timer is gone after this. `None`
+/// when it was cleared (or the realm has no timers).
+pub(crate) fn due(ctx: &Context, id: u32) -> Option {
+ let mut timers = ctx.get_data::()?.borrow_mut();
+ let entry = timers.table.get(&id)?;
+ let code = entry.code.clone();
+ if !entry.repeat {
+ timers.table.remove(&id);
+ }
+ Some(code)
+}
+
+/// `app.setInterval(cExpr, nMilliseconds)`: run the expression (or a function) every period
+/// until it is cleared.
+pub(crate) fn set_interval(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult {
+ set(args, true, ctx)
+}
+
+/// `app.setTimeOut(cExpr, nMilliseconds)`: run the expression (or a function) once, after the
+/// period.
+pub(crate) fn set_time_out(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult {
+ set(args, false, ctx)
+}
+
+fn set(args: &[JsValue], repeat: bool, ctx: &mut Context) -> JsResult {
+ let handle = ObjectInitializer::new(ctx).build();
+ if ctx.get_data::().is_none() {
+ return Ok(handle.into());
+ }
+ let what = arg(args, 0);
+ let code = match what.as_callable() {
+ Some(f) => Code::Function(f.clone()),
+ None => {
+ let source = text(&what, ctx)?;
+ if source.len() > MAX_SCRIPT_BYTES {
+ return Err(error(format!("the timer's script is too long (the limit is {} KiB)", MAX_SCRIPT_BYTES / 1024)));
+ }
+ Code::Source(source)
+ }
+ };
+ let ms = arg(args, 1).to_number(ctx)?;
+ // A missing, negative or NaN period runs as soon as allowed; the float cast saturates.
+ let period_ms = if ms.is_nan() { MIN_TIMER_MS } else { (ms.min(f64::from(u32::MAX)) as u32).max(MIN_TIMER_MS) };
+ let Some(timers) = ctx.get_data::() else { return Ok(handle.into()) };
+ let mut timers = timers.borrow_mut();
+ if timers.table.len() >= MAX_TIMERS {
+ return Err(error(format!("a document's scripts can keep at most {MAX_TIMERS} timers")));
+ }
+ let id = timers.last.checked_add(1).ok_or_else(|| error("no more timers can be set"))?;
+ timers.last = id;
+ timers.table.insert(id, Entry { handle: handle.clone(), code, period_ms, repeat });
+ Ok(handle.into())
+}
+
+/// `app.clearInterval(oInterval)` and `app.clearTimeOut(oTime)`: forget the timer the object
+/// stands for. Anything else, or a timer already gone, is ignored.
+pub(crate) fn clear(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult {
+ if let Some(o) = arg(args, 0).as_object()
+ && let Some(timers) = ctx.get_data::()
+ {
+ timers.borrow_mut().table.retain(|_, e| !JsObject::equals(&e.handle, &o));
+ }
+ Ok(JsValue::undefined())
+}