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()) +}