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 <p.yatsyshin@googlemail.com>
This commit is contained in:
FredAppsandPeter Yatsyshin committed 2026-11-11 10:39:13 +01:00
1 parent 4efcfa6ea3
commit f56a146b22
7 files changed
+261 -13

No files matched your search

+4
View File
@@ -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)?;
+4 -9
View File
@@ -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<JsValue> {
// 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)
+2
View File
@@ -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;
+3
View File
@@ -382,6 +382,9 @@ pub struct Outcome {
pub doc_errors: Vec<String>,
/// The script's completion value (what the console prints), unless undefined.
pub result: Option<String>,
/// 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<crate::Timer>,
}
/// Bounds that keep a script from hanging the viewer.
+11 -2
View File
@@ -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<JsObject>,
}
/// 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
}
+105 -2
View File
@@ -16,6 +16,10 @@ pub struct Job {
pub doc_scripts: Vec<String>,
/// 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<u32>,
}
/// 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<crate::Timer>| 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<_>>(), [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.
+132
View File
@@ -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<u32, Entry>,
}
type Shared = RefCell<Timers>;
/// 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::<Shared>().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<Timer> {
ctx.get_data::<Shared>()
.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<Code> {
let mut timers = ctx.get_data::<Shared>()?.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<JsValue> {
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<JsValue> {
set(args, false, ctx)
}
fn set(args: &[JsValue], repeat: bool, ctx: &mut Context) -> JsResult<JsValue> {
let handle = ObjectInitializer::new(ctx).build();
if ctx.get_data::<Shared>().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::<Shared>() 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<JsValue> {
if let Some(o) = arg(args, 0).as_object()
&& let Some(timers) = ctx.get_data::<Shared>()
{
timers.borrow_mut().table.retain(|_, e| !JsObject::equals(&e.handle, &o));
}
Ok(JsValue::undefined())
}