Files
pdfcraft/crates/js/src/session.rs
T
FredApps ee081d033e fix(scripts): keep timers past a refused script; page actions before timers
A script refused before it runs (too long, or nested too deeply) left the
realm untouched but reported no timers. The viewer keeps only the timers
a run reports, so a button or console script over the limit stopped every
page timer. The realm now reports the timers it still has on that path.

The viewer built the page actions' run and the timers' run before it
handled either, so a due timer ran before what the page's open action
asked for: on a page that goes on to another, before that page's close
action could stop it. The timers now run after the page actions, and only
if those left the page, the tab and the dialogs as they were; otherwise
they wait a frame. The focused field's editor text is taken before each
run, not once for both.

Tests: a_script_refused_before_it_runs_reports_the_realms_timers (js),
a_script_refused_before_it_runs_keeps_the_pages_timers (engine),
timers_wait_for_the_page_a_page_action_goes_to (ui). Each fails without
its fix.
2026-11-11 19:48:17 +01:00

1302 lines
61 KiB
Rust

//! One sandboxed realm per open document. No context crosses a document boundary.
#[cfg(not(target_arch = "wasm32"))]
use crate::interaction::InteractiveEvent;
use crate::{DocInfo, Event, FieldState, Limits, Outcome};
use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::mpsc;
/// One event snapshot; the worker owns its context and refreshes this data between invocations.
#[derive(Clone)]
pub struct Job {
pub script: String,
pub event: Event,
pub doc: DocInfo,
pub fields: Arc<Vec<FieldState>>,
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)]
struct FieldInstances {
current: std::collections::HashMap<u64, u64>,
last_ids: Vec<u64>,
next: u64,
}
impl FieldInstances {
/// Retire absent instances immediately, including history changes between two script jobs.
fn sync(&mut self, ids: impl IntoIterator<Item = u64>) {
let ids: Vec<_> = ids.into_iter().collect();
if ids == self.last_ids {
return;
}
let present: std::collections::BTreeSet<_> = ids.iter().copied().collect();
self.last_ids = ids;
self.current.retain(|id, _| present.contains(id));
for id in present {
if !self.current.contains_key(&id) {
self.next = self.next.saturating_add(1);
self.current.insert(id, self.next);
}
}
}
/// Stamp the immutable job snapshot with current instance tokens before worker dispatch.
fn stamp(&mut self, job: &mut Job) {
self.stamp_fields(&mut job.fields);
}
/// Stamp a snapshot in place, copying it only when a token actually changes: a caller that
/// keeps its snapshot between callbacks (the engine does) then shares it with the worker.
fn stamp_fields(&mut self, fields: &mut Arc<Vec<FieldState>>) {
self.sync(fields.iter().filter_map(|f| f.object_id));
let token =
|current: &std::collections::HashMap<u64, u64>, field: &FieldState| field.object_id.and_then(|id| current.get(&id).copied()).unwrap_or(0);
if fields.iter().all(|field| field.instance_id == token(&self.current, field)) {
return;
}
for field in Arc::make_mut(fields) {
field.instance_id = token(&self.current, field);
}
}
}
impl ScriptSession {
/// Stamp the caller's retained snapshot with the current instance tokens before it builds a
/// job, so the worker's own stamping finds nothing to change and does not copy it.
pub fn stamp_fields(&mut self, fields: &mut Arc<Vec<FieldState>>) {
self.instances.stamp_fields(fields);
}
/// Notify the retained runtime of live PDF fields after an edit or history step.
pub fn sync_field_ids(&mut self, ids: impl IntoIterator<Item = u64>) {
self.instances.sync(ids);
}
/// Invalidate icon handles after restoring an earlier PDF object graph.
pub fn retire_icon_handles(&mut self) {
self.icon_epoch = self.icon_epoch.wrapping_add(1);
self.icon_versions = Default::default();
self.icon_tracking_overflowed = false;
}
/// Invalidate only the recorded references that changed in a history step.
pub fn retire_changed_icons(&mut self, mut changed: impl FnMut(u64) -> bool) {
if self.icon_tracking_overflowed {
self.retire_icon_handles();
return;
}
for (id, version) in Arc::make_mut(&mut self.icon_versions) {
if changed(*id) {
*version = version.wrapping_add(1);
}
}
}
/// Attach icon versions to the snapshot and record when history must invalidate all handles because tracking filled up.
fn stamp_icons(&mut self, job: &mut Job) {
const MAX_TRACKED_ICONS: usize = 16_384;
for id in job.fields.iter().filter_map(|field| field.icon) {
if self.icon_versions.contains_key(&id) {
continue;
}
if self.icon_versions.len() == MAX_TRACKED_ICONS {
self.icon_tracking_overflowed = true;
break;
}
Arc::make_mut(&mut self.icon_versions).insert(id, 0);
}
job.doc.icon_epoch = self.icon_epoch;
job.doc.icon_versions = self.icon_versions.clone();
}
}
/// Boa context and initialization sources retained by exactly one open-document runtime.
pub(crate) struct Realm {
context: Option<crate::Context>,
scripts: Vec<String>,
}
impl Realm {
/// Create an empty isolated realm; the first job initializes its document scripts.
fn new() -> Self {
Self { context: None, scripts: Vec::new() }
}
/// Validate sources, initialize once when needed, and execute this job in the retained document realm.
fn run(&mut self, job: Job) -> Outcome {
let out = self.run_job(job);
// The realm keeps its globals and field objects, not the job's field snapshot: the engine
// keeps that snapshot and patches it in place for the next callback.
if let Some(ctx) = self.context.as_ref() {
crate::run::release_fields(ctx);
}
out
}
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, so its
// timers are as they were: they are reported, as after any run, or the viewer drops them.
if let Some(why) = crate::refuse(&job.script) {
return Outcome {
rc: true,
realm_untouched: true,
value: job.event.value.clone(),
change: job.event.change.clone(),
error: Some(why),
timers: self.context.as_ref().map(crate::timers::report).unwrap_or_default(),
..Default::default()
};
}
if job.doc_scripts.len() > 1024 || job.doc_scripts.iter().try_fold(0usize, |n, s| n.checked_add(s.len())).is_none_or(|n| n > 1 << 20) {
return failed("document scripts exceed their budget".into());
}
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(mut c) => {
crate::timers::enable(&mut c);
Some(c)
}
Err(e) => return failed(e.to_string()),
};
self.scripts = job.doc_scripts.clone();
}
let mut skipped = Vec::new();
let scripts: Vec<_> = job
.doc_scripts
.iter()
.enumerate()
.filter_map(|(i, s)| {
if !initialize {
return None;
}
match crate::refuse(s) {
Some(e) => {
skipped.push((i, e));
None
}
None => Some((i, s.as_str())),
}
})
.collect();
let Some(ctx) = self.context.as_mut() else { return failed("script realm unavailable".into()) };
if initialize {
let mut initial = crate::run::run_in_context(
ctx,
"",
&Event::doc("Open"),
&job.doc,
job.fields.clone(),
crate::run::ScriptSetup {
doc_scripts: &scripts,
skipped: &skipped,
calculations: &job.calculations,
initialize: true,
reset_calls: 0,
calculation_evaluations: 0,
calculation_limit_exceeded: false,
call: None,
},
Limits::default(),
);
if initial.execution_refused {
return initial;
}
let (fields, reset_calls, calculation_evaluations) = crate::host::host(ctx)
.map(|h| {
let h = h.borrow();
(h.fields.clone(), h.reset_calls, h.calculation_evaluations)
})
.unwrap_or((job.fields, 0, 0));
let mut out = crate::run::run_in_context(
ctx,
&job.script,
&job.event,
&job.doc,
fields,
crate::run::ScriptSetup {
doc_scripts: &[],
skipped: &[],
calculations: &job.calculations,
initialize: false,
reset_calls,
calculation_evaluations,
calculation_limit_exceeded: initial.calculation_limit_exceeded,
call: None,
},
Limits::default(),
);
out.reset_limit_exceeded |= initial.reset_limit_exceeded;
out.calculation_limit_exceeded |= initial.calculation_limit_exceeded;
for field in initial.changed {
if !out.changed.iter().any(|f| match (f.object_id, field.object_id) {
(Some(a), Some(b)) => a == b,
_ => f.name == field.name,
}) {
out.changed.push(field);
}
}
initial.alerts.append(&mut out.alerts);
out.alerts = initial.alerts;
initial.console.append(&mut out.console);
out.console = initial.console;
initial.requests.append(&mut out.requests);
out.requests = initial.requests;
out.doc_errors = initial.doc_errors;
if out.error.is_none() {
out.error = initial.error;
}
out
} else {
crate::run::run_in_context(
ctx,
&job.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: None,
},
Limits::default(),
)
}
}
}
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"))]
const MAX_DOCUMENT_WORKERS: usize = if usize::BITS == 32 { 4 } else { 16 };
/// A reservation is released only when its worker exits, including detached timed-out workers.
#[cfg(not(target_arch = "wasm32"))]
struct WorkerPermit;
#[cfg(not(target_arch = "wasm32"))]
impl WorkerPermit {
/// Reserve bounded address space without evicting another document's retained state.
fn acquire() -> Result<Self, String> {
use std::sync::atomic::Ordering::SeqCst;
let mut live = LIVE_WORKERS.load(SeqCst);
loop {
if live >= MAX_DOCUMENT_WORKERS {
return Err("document script worker limit reached; close a scripted document and retry".into());
}
match LIVE_WORKERS.compare_exchange(live, live.saturating_add(1), SeqCst, SeqCst) {
Ok(_) => return Ok(Self),
Err(current) => live = current,
}
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Drop for WorkerPermit {
fn drop(&mut self) {
LIVE_WORKERS.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
}
}
#[cfg(not(target_arch = "wasm32"))]
type HostTask = Box<dyn FnOnce(&mut crate::Context) -> Outcome + Send>;
#[cfg(not(target_arch = "wasm32"))]
enum WorkerRequest {
Script { job: Box<Job>, english_retry: bool },
Host(HostTask),
}
#[cfg(not(target_arch = "wasm32"))]
struct WorkerJob {
request: WorkerRequest,
reset_realm: bool,
reply: mpsc::Sender<InteractiveEvent>,
}
#[cfg(not(target_arch = "wasm32"))]
/// Native document runtime with one bounded-stack worker and at most one active invocation.
/// Dropping it releases the sender; after any active job finishes the worker exits and drops its realm.
#[derive(Default)]
pub struct ScriptSession {
sender: Option<mpsc::SyncSender<WorkerJob>>,
executing: Arc<std::sync::atomic::AtomicBool>,
reset_realm: bool,
busy: Arc<std::sync::atomic::AtomicBool>,
worker_lifetime: std::sync::Weak<()>,
instances: FieldInstances,
icon_epoch: u64,
icon_versions: Arc<std::collections::BTreeMap<u64, u64>>,
icon_tracking_overflowed: bool,
}
#[cfg(not(target_arch = "wasm32"))]
impl ScriptSession {
/// Start a bounded-stack native worker lazily and reject a second active job, including during prompts.
pub fn start(&mut self, job: Job) -> Result<mpsc::Receiver<InteractiveEvent>, String> {
self.start_job(WorkerRequest::Script { job: Box::new(job), english_retry: false })
}
/// Reserve the document worker and enqueue a job. An English-date retry uses a fresh context and leaves the retained realm alone.
fn start_job(&mut self, mut request: WorkerRequest) -> Result<mpsc::Receiver<InteractiveEvent>, String> {
use std::sync::atomic::Ordering::SeqCst;
if self.is_executing() {
return Err("another script is active in this document".into());
}
if self.sender.is_none() {
if self.worker_lifetime.upgrade().is_some() {
return Err("the timed-out script is still stopping; retry once it exits".into());
}
let (tx, rx) = mpsc::sync_channel::<WorkerJob>(1);
let permit = WorkerPermit::acquire()?;
let lifetime = Arc::new(());
self.worker_lifetime = Arc::downgrade(&lifetime);
let executing = self.executing.clone();
std::thread::Builder::new()
.name("pdfcraft-document-js".into())
.stack_size(crate::run::DOCUMENT_WORKER_STACK)
.spawn(move || {
let _lifetime = lifetime;
let _permit = permit;
let mut realm = Realm::new();
while let Ok(WorkerJob { request, reset_realm, reply }) = rx.recv() {
if reset_realm {
realm = Realm::new();
}
let interactive = matches!(&request, WorkerRequest::Script { english_retry: false, .. });
crate::interaction::set_host(interactive.then(|| reply.clone()));
let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match request {
WorkerRequest::Script { job, english_retry: true } => crate::with_english_printd(true, || {
crate::run::run_shared_here(
&job.script,
&job.event,
&job.doc,
job.fields,
&job.doc_scripts,
&job.calculations,
Limits::default(),
)
}),
WorkerRequest::Script { job, english_retry: false } => realm.run(*job),
WorkerRequest::Host(task) => match realm.context.as_mut() {
Some(context) => task(context),
None => Outcome {
execution_refused: true,
realm_untouched: true,
error: Some("the document script realm has not been initialized".into()),
..Default::default()
},
},
}));
crate::interaction::set_host(None);
let out = out.unwrap_or_else(|_| {
realm = Realm::new();
Outcome {
rc: true,
execution_refused: true,
error: Some("script engine stopped unexpectedly".into()),
..Default::default()
}
});
executing.store(false, SeqCst);
let _ = reply.send(InteractiveEvent::Complete(out));
}
})
.map_err(|e| e.to_string())?;
self.sender = Some(tx);
}
if self.busy.compare_exchange(false, true, std::sync::atomic::Ordering::SeqCst, std::sync::atomic::Ordering::SeqCst).is_err() {
return Err("another script is active in this document".into());
}
if let WorkerRequest::Script { job, english_retry: false } = &mut request {
self.instances.stamp(job);
self.stamp_icons(job);
}
let (tx, rx) = mpsc::channel();
let Some(sender) = self.sender.as_ref() else {
self.busy.store(false, std::sync::atomic::Ordering::SeqCst);
return Err("script worker unavailable".into());
};
self.executing.store(true, SeqCst);
sender.try_send(WorkerJob { request, reset_realm: self.reset_realm, reply: tx }).map_err(|_| {
self.executing.store(false, SeqCst);
self.busy.store(false, SeqCst);
"script worker disconnected"
})?;
self.reset_realm = false;
Ok(rx)
}
/// Whether the native worker still owns an invocation, including one waiting for a prompt.
pub fn is_executing(&self) -> bool {
self.executing.load(std::sync::atomic::Ordering::SeqCst)
}
/// An asynchronous completion remains pending until its document outcome is accepted or discarded.
pub fn is_pending(&self) -> bool {
self.busy.load(std::sync::atomic::Ordering::SeqCst)
}
/// Acknowledge a received completion before applying its edits on the same document realm.
pub fn acknowledge_completion(&mut self) {
self.busy.store(false, std::sync::atomic::Ordering::SeqCst);
}
/// Detach the old worker and retain its lifetime so it still occupies this document's slot.
pub fn retire_timed_out_worker(&mut self) {
self.sender = None;
self.acknowledge_completion();
}
/// Fork the realm while keeping this document's worker reservation and execution gate.
/// Old invocation handles cannot submit work through the successor's channel.
pub fn fresh_realm(&mut self) -> Self {
Self {
sender: self.sender.take(),
executing: self.executing.clone(),
worker_lifetime: self.worker_lifetime.clone(),
reset_realm: true,
..Self::default()
}
}
/// Drop state from callbacks belonging to a rejected PDF edit.
pub fn reset_after_refusal(&mut self) {
*self = self.fresh_realm();
}
/// Run a job with one total deadline, discarding its entire realm when the host times out.
pub fn run(&mut self, job: Job, answers: &[Option<String>]) -> Outcome {
self.run_with_limit(job, answers, std::time::Duration::from_secs(10))
}
/// Run a callback within the remaining time of a larger operation.
pub fn run_with_limit(&mut self, job: Job, answers: &[Option<String>], limit: std::time::Duration) -> Outcome {
self.run_timed(WorkerRequest::Script { job: Box::new(job), english_retry: false }, answers, limit)
}
/// Retry formatting in a fresh context on the document worker, within the caller's remaining time.
pub fn run_english_retry(&mut self, job: Job, limit: std::time::Duration) -> Outcome {
self.run_timed(WorkerRequest::Script { job: Box::new(job), english_retry: true }, &[], limit)
}
/// Dispatch trusted host work to the document's already initialized realm.
/// The callback stays on the document worker and must return cooperatively on its own.
pub fn run_host_task(&mut self, limit: std::time::Duration, task: impl FnOnce(&mut crate::Context) -> Outcome + Send + 'static) -> Outcome {
self.run_timed(WorkerRequest::Host(Box::new(task)), &[], limit)
}
/// Wait within one deadline for startup, prompt replies and completion. Detach a worker that exceeds the deadline.
fn run_timed(&mut self, request: WorkerRequest, answers: &[Option<String>], limit: std::time::Duration) -> Outcome {
if limit.is_zero() {
return Outcome {
execution_refused: true,
realm_untouched: true,
error: Some("script worker had no time remaining".into()),
..Default::default()
};
}
let started = std::time::Instant::now();
let rx = match self.start_job(request) {
Ok(rx) => rx,
Err(e) => return Outcome { execution_refused: true, realm_untouched: true, error: Some(e), ..Default::default() },
};
match Self::receive(&rx, answers, started, limit) {
Some(out) => {
self.acknowledge_completion();
out
}
None => {
// The old worker has no route into this replacement realm. Its reservation
// remains charged until cooperative execution finishes and the thread exits.
self.retire_timed_out_worker();
Outcome { execution_refused: true, error: Some("script worker did not complete within its limit".into()), ..Default::default() }
}
}
}
/// Prompt replies consume the original deadline instead of restarting it.
fn receive(
rx: &mpsc::Receiver<InteractiveEvent>,
answers: &[Option<String>],
started: std::time::Instant,
limit: std::time::Duration,
) -> Option<Outcome> {
let mut answers = answers.iter();
loop {
let remaining = limit.saturating_sub(started.elapsed());
if remaining.is_zero() {
return None;
}
match rx.recv_timeout(remaining) {
Ok(InteractiveEvent::Complete(out)) => return Some(out),
Ok(InteractiveEvent::Prompt(_, reply)) => {
let _ = reply.send(answers.next().cloned().flatten());
}
Err(_) => return None,
}
}
}
}
/// Web-local retained realm; native worker/dialog hosting is deliberately unavailable.
#[cfg(target_arch = "wasm32")]
pub struct ScriptSession {
realm: Realm,
instances: FieldInstances,
icon_epoch: u64,
icon_versions: Arc<std::collections::BTreeMap<u64, u64>>,
icon_tracking_overflowed: bool,
}
#[cfg(target_arch = "wasm32")]
impl Default for ScriptSession {
/// Create the web runtime without native worker threads or a native prompt host.
fn default() -> Self {
Self {
realm: Realm::new(),
instances: FieldInstances::default(),
icon_epoch: 0,
icon_versions: Default::default(),
icon_tracking_overflowed: false,
}
}
}
#[cfg(target_arch = "wasm32")]
impl ScriptSession {
/// Replace the web-local realm and its document bindings.
pub fn fresh_realm(&mut self) -> Self {
Self::default()
}
/// Drop state from callbacks belonging to a rejected PDF edit.
pub fn reset_after_refusal(&mut self) {
*self = Self::default();
}
/// Run in the web-local realm; native prompt replies are unavailable on this host.
pub fn run(&mut self, mut job: Job, _answers: &[Option<String>]) -> Outcome {
self.instances.stamp(&mut job);
self.stamp_icons(&mut job);
self.realm.run(job)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Create a small button-event job with document globals used by persistent-session regressions.
fn job(source: &str) -> Job {
Job {
script: source.into(),
event: Event::field("Mouse Up", "go", ""),
doc: DocInfo::default(),
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!(s.run(fire(2), &[]).realm_untouched, "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]
/// A script refused before it runs (too long) leaves the realm as it was and reports the
/// timers it still has: the viewer keeps only the timers a run reports.
fn a_script_refused_before_it_runs_reports_the_realms_timers() {
let mut s = ScriptSession::default();
let set = s.run(job("app.setInterval('1', 40);"), &[]);
assert_eq!(set.timers.len(), 1);
let refused = s.run(job(&"1;".repeat(crate::MAX_SCRIPT_BYTES)), &[]);
assert!(refused.realm_untouched && !refused.execution_refused && refused.error.is_some(), "{:?}", refused.error);
assert_eq!(refused.timers, set.timers);
}
#[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() {
let mut a = ScriptSession::default();
let mut b = ScriptSession::default();
assert_eq!(a.run(job("digit('1');"), &[]).result.as_deref(), Some("1"));
assert_eq!(a.run(job("getField('go').custom=42;getField('go')===held;"), &[]).result.as_deref(), Some("true"));
assert_eq!(a.run(job("getField('go').custom;"), &[]).result.as_deref(), Some("42"));
assert_eq!(a.run(job("[digit('2'),init,opened].join('/');"), &[]).result.as_deref(), Some("12/1/Open"));
assert_eq!(b.run(job("digit('3');"), &[]).result.as_deref(), Some("3"));
let mut changed = job("held.value;");
changed.fields = Arc::new(vec![FieldState::new("go", crate::FieldType::Button, vec!["live".into()])]);
assert_eq!(a.run(changed, &[]).result.as_deref(), Some("live"));
let mut edited = job("buffer;");
edited.doc_scripts = vec!["var buffer='changed';".into()];
assert_eq!(a.run(edited, &[]).result.as_deref(), Some("changed"));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn host_work_reads_globals_in_the_correct_document() {
let read_counter = |context: &mut crate::Context| {
let result = context
.global_object()
.get(boa_engine::js_string!("counter"), context)
.ok()
.and_then(|value| value.as_number())
.map(|number| number.to_string());
Outcome { result, ..Default::default() }
};
let mut first = ScriptSession::default();
let mut second = ScriptSession::default();
assert!(first.run(job("var counter = 17;"), &[]).error.is_none());
assert!(second.run(job("var counter = 31;"), &[]).error.is_none());
let timeout = std::time::Duration::from_secs(5);
assert_eq!(first.run_host_task(timeout, read_counter).result.as_deref(), Some("17"));
assert_eq!(second.run_host_task(timeout, read_counter).result.as_deref(), Some("31"));
}
#[test]
/// A script too large to run fails on its own, like a script that throws, without refusing the edit.
fn an_oversized_script_is_an_error_not_a_refusal() {
let mut session = ScriptSession::default();
let long = format!("event.value = 1;/*{}*/", "x".repeat(300 * 1024));
let out = session.run(job(&long), &[]);
assert!(out.error.is_some());
assert!(!out.execution_refused);
assert!(session.run(job("1 + 1"), &[]).error.is_none());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
/// A waiting prompt refuses concurrent document runs immediately instead of blocking the caller behind it.
fn a_waiting_prompt_refuses_reentrant_host_runs_without_blocking_the_ui() {
let mut session = ScriptSession::default();
let receiver = session.start(job("app.response({cQuestion:'Wait'});")).unwrap();
let InteractiveEvent::Prompt(_, reply) = receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap() else { panic!("expected prompt") };
let started = std::time::Instant::now();
let refused = session.run(job("buffer;"), &[]);
assert!(refused.error.as_deref().unwrap().contains("another script is active"));
let refused = session.run_host_task(std::time::Duration::from_secs(1), |_| panic!("host task entered a busy realm"));
assert!(refused.execution_refused && refused.realm_untouched);
assert!(started.elapsed() < std::time::Duration::from_secs(1));
reply.send(None).unwrap();
assert!(matches!(receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap(), InteractiveEvent::Complete(_)));
}
/// A retained realm must expose current metadata without replaying document initialization.
#[test]
fn persistent_document_properties_follow_the_current_snapshot() {
let mut session = ScriptSession::default();
let source = "[numPages,documentFileName,info.title,init,buffer].join('/');";
let mut first = job(source);
first.doc = DocInfo { num_pages: 1, file_name: "before.pdf".into(), info: vec![("title".into(), "Before".into())], ..Default::default() };
assert_eq!(session.run(first, &[]).result.as_deref(), Some("1/before.pdf/Before/1/"));
assert_eq!(session.run(job("buffer='kept';"), &[]).error, None);
let mut next = job(source);
next.doc = DocInfo { num_pages: 7, file_name: "after.pdf".into(), info: vec![("title".into(), "After".into())], ..Default::default() };
assert_eq!(session.run(next, &[]).result.as_deref(), Some("7/after.pdf/After/1/kept"));
let mut removed = job("numPages===0 && documentFileName==='' && typeof info.title==='undefined' && buffer==='kept';");
removed.doc = DocInfo::default();
assert_eq!(session.run(removed, &[]).result.as_deref(), Some("true"));
}
/// Observe real address-space growth for four simultaneously live document workers.
#[cfg(target_os = "linux")]
#[test]
fn persistent_workers_do_not_reserve_a_gibibyte_per_document() {
const CHILD: &str = "PDFCRAFT_WORKER_RESERVATION_TEST_CHILD";
if std::env::var_os(CHILD).is_none() {
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "session::tests::persistent_workers_do_not_reserve_a_gibibyte_per_document", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
assert!(result.status.success(), "reservation child failed: {}", String::from_utf8_lossy(&result.stderr));
eprintln!("{}", String::from_utf8_lossy(&result.stderr));
return;
}
/// Read the kernel's virtual-size metric, in bytes, for this isolated test process.
fn virtual_bytes() -> u64 {
let status = std::fs::read_to_string("/proc/self/status").unwrap();
status.lines().find_map(|line| line.strip_prefix("VmSize:")?.split_whitespace().next()?.parse::<u64>().ok()).unwrap() * 1024
}
let before = virtual_bytes();
let mut workers: Vec<_> = (0..4).map(|_| ScriptSession::default()).collect();
for worker in &mut workers {
assert_eq!(worker.run(job("digit('1');"), &[]).result.as_deref(), Some("1"));
}
let growth = virtual_bytes().saturating_sub(before);
eprintln!("four live document workers: virtual address-space growth {growth} bytes");
assert!(growth < 768 * 1024 * 1024, "four workers reserved {growth} bytes");
for worker in &mut workers {
assert_eq!(worker.run(job("digit('2');"), &[]).result.as_deref(), Some("12"));
}
}
/// Run parser/compiler boundary cases in a child process so a stack abort is observable.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn document_worker_contains_hostile_and_boundary_scripts() {
const CHILD: &str = "PDFCRAFT_WORKER_STACK_TEST_CHILD";
if std::env::var_os(CHILD).is_none() {
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "session::tests::document_worker_contains_hostile_and_boundary_scripts", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
assert!(result.status.success(), "worker child failed: {}", String::from_utf8_lossy(&result.stderr));
return;
}
let mut worker = ScriptSession::default();
for script in [
format!("{}1{}", "(".repeat(100_000), ")".repeat(100_000)),
format!("{}1", "!".repeat(100_000)),
format!("{}1", "1?1:".repeat(10_000)),
format!("{}1", "a=>".repeat(100_000)),
format!("{}1", "a=>".repeat(70_000)),
format!("{}1", "a=>".repeat(257)),
"function recursive(){return recursive();} recursive();".into(),
"while(true){}".into(),
] {
assert!(worker.run(job(&script), &[]).error.is_some());
}
for (script, expected) in [
(format!("{}1{}", "(".repeat(64), ")".repeat(64)), "1"),
(format!("1{}", "+1".repeat(50_000)), "50001"),
(format!("var a;{}1;true;", "a=".repeat(4096)), "true"),
(format!("{}1;true;", "1**".repeat(4096)), "true"),
(format!("{}1;true;", "void ".repeat(64)), "true"),
(format!("{}1;true;", "typeof ".repeat(64)), "true"),
(format!("{}1", "false?1:".repeat(4096)), "1"),
(format!("{}1", "!".repeat(64)), "true"),
(format!("typeof ({}1)", "a=>".repeat(256)), "function"),
(format!("[{}].length", vec!["()=>1"; 256].join(",")), "256"),
("function bounded(n){return n?bounded(n-1)+1:0;} bounded(32);".into(), "32"),
] {
let result = worker.run(job(&script), &[]);
assert_eq!(result.error, None);
assert_eq!(result.result.as_deref(), Some(expected));
}
assert_eq!(worker.run(job("digit('7');"), &[]).result.as_deref(), Some("7"));
}
/// Isolate unbracketed parser recursion families, which evade bracket-depth checks.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn document_worker_bounds_unbracketed_expression_chains() {
const CHILD: &str = "PDFCRAFT_EXPRESSION_CHAIN_CHILD";
let Ok(case) = std::env::var(CHILD) else {
for case in 0..5 {
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "session::tests::document_worker_bounds_unbracketed_expression_chains", "--nocapture"])
.env(CHILD, case.to_string())
.output()
.unwrap();
assert!(result.status.success(), "expression family {case}: {}", String::from_utf8_lossy(&result.stderr));
}
return;
};
let script = match case.as_str() {
"0" => format!("var a;{}1", "a=".repeat(70_000)),
"1" => format!("{}1", "1**".repeat(60_000)),
"2" => format!("{}1", "void ".repeat(40_000)),
"3" => format!("{}Object", "new ".repeat(50_000)),
_ => format!("var a;{}{}{}1", "a=".repeat(4096), "false?1:".repeat(4096), "1**".repeat(4096)),
};
let mut worker = ScriptSession::default();
let outcome = worker.run(job(&script), &[]);
if case == "4" {
// Combining individually permitted boundary chains must also fit the worker.
assert_eq!(outcome.error, None);
assert_eq!(outcome.result.as_deref(), Some("1"));
} else {
assert!(outcome.error.is_some(), "over-budget chain was admitted");
}
assert_eq!(worker.run(job("digit('8');"), &[]).result.as_deref(), Some("8"));
}
/// Independent statements and comparisons must not consume a source-wide nesting budget.
#[test]
fn document_worker_accepts_large_flat_libraries() {
let scripts = [
format!("var a=1,b=0;{}b;", "if(a===1){b=2;}".repeat(2000)),
format!("{}f299();", (0..300).map(|i| format!("const f{i}=()=>1;")).collect::<String>()),
format!("{}f299()", (0..300).map(|i| format!("const f{i}=()=>1\n")).collect::<String>()),
format!("var a=0\n{}a", "a=2**3\n".repeat(6000)),
format!("var a=0;{}a;", "a+=1;".repeat(6000)),
format!("var a=0;{}a;", "a=2**3;".repeat(6000)),
// Within the nesting guard's 4,096 conditionals.
format!("var a=0;{}a;", "a=(1===1)?1:2;".repeat(4000)),
];
for (script, expected) in scripts.into_iter().zip(["2", "1", "1", "8", "6000", "8", "1"]) {
let mut worker = ScriptSession::default();
let result = worker.run(job(&script), &[]);
assert_eq!(result.error, None);
assert_eq!(result.result.as_deref(), Some(expected));
}
let block = format!("function library(){{var x=0\n{}return x}}\nlibrary()", "x=1\n".repeat(6000));
assert_eq!(ScriptSession::default().run(job(&block), &[]).result.as_deref(), Some("1"));
let mut document = job("f299();");
document.doc_scripts.push((0..300).map(|i| format!("const f{i}=()=>1\n")).collect::<String>());
let result = ScriptSession::default().run(document, &[]);
assert_eq!(result.error, None);
assert!(result.doc_errors.is_empty());
assert_eq!(result.result.as_deref(), Some("1"));
}
#[test]
/// Retaining the realm must not re-enable eval or dynamic Function construction.
fn persistent_context_keeps_dynamic_compilation_blocked() {
let mut a = ScriptSession::default();
assert!(a.run(job("eval('1')"), &[]).error.is_some());
assert!(a.run(job("new Function('return 1')()"), &[]).error.is_some());
}
/// The completion itself must not release the realm before its caller accepts the outcome.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn completion_requires_acknowledgement_before_the_next_job() {
let mut session = ScriptSession::default();
let rx = session.start(job("buffer='pending';")).unwrap();
assert!(matches!(rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap(), InteractiveEvent::Complete(_)));
assert!(session.run(job("buffer"), &[]).error.is_some());
session.acknowledge_completion();
assert_eq!(session.run(job("buffer"), &[]).result.as_deref(), Some("pending"));
}
/// Multiple prompt round trips share one wall-clock budget.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn synchronous_prompt_waits_share_one_deadline() {
use std::time::{Duration, Instant};
let (tx, rx) = mpsc::channel();
let producer = std::thread::spawn(move || {
for _ in 0..3 {
std::thread::sleep(Duration::from_millis(50));
let (answer, reply) = mpsc::channel();
if tx.send(InteractiveEvent::Prompt(crate::interaction::Prompt::Menu(Vec::new()), answer)).is_err() {
return;
}
if reply.recv_timeout(Duration::from_secs(1)).is_err() {
return;
}
}
let _ = tx.send(InteractiveEvent::Complete(Outcome::default()));
});
assert!(ScriptSession::receive(&rx, &[], Instant::now(), Duration::from_millis(80)).is_none());
drop(rx);
producer.join().unwrap();
}
/// A timed-out invocation cannot seed globals/custom Field properties in its replacement realm.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn timed_out_jobs_never_share_the_next_realm() {
let mut session = ScriptSession::default();
use std::time::{Duration, Instant};
let out = session.run_with_limit(job("buffer='late';held.custom=99;"), &[], Duration::ZERO);
assert!(out.execution_refused);
let deadline = Instant::now() + Duration::from_secs(2);
while session.worker_lifetime.upgrade().is_some() {
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(1));
}
assert_eq!(session.run(job("buffer+'/'+typeof held.custom"), &[]).result.as_deref(), Some("/undefined"));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_detached_worker_holds_the_documents_slot_until_it_exits() {
let worker = Arc::new(());
let mut session = ScriptSession { worker_lifetime: Arc::downgrade(&worker), ..Default::default() };
let out = session.run(job("1+1"), &[]);
assert!(out.execution_refused);
assert!(session.sender.is_none());
drop(worker);
assert_eq!(session.run(job("1+1"), &[]).result.as_deref(), Some("2"));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn english_retry_is_isolated_and_obeys_the_remaining_time() {
use std::time::Duration;
let mut session = ScriptSession::default();
assert_eq!(session.run(job("var retained = 17; retained;"), &[]).result.as_deref(), Some("17"));
let retry = session.run_english_retry(job("var retryOnly = 1; typeof retained;"), Duration::from_secs(2));
assert_eq!(retry.result.as_deref(), Some("undefined"));
assert_eq!(session.run(job("retained + '/' + typeof retryOnly;"), &[]).result.as_deref(), Some("17/undefined"));
let expired = session.run_english_retry(job("1;"), Duration::ZERO);
assert!(expired.execution_refused);
}
#[test]
fn icon_tracking_overflow_preserves_handles_until_history_changes() {
let mut session = ScriptSession::default();
Arc::make_mut(&mut session.icon_versions).extend((1..=16_384).map(|id| (id, 0)));
let mut capture = job("var oldIcon = getField('go').buttonGetIcon();");
Arc::make_mut(&mut capture.fields)[0].icon = Some(16_385);
assert_eq!(session.run(capture.clone(), &[]).error, None);
capture.script = "getField('go').buttonSetIcon(oldIcon);".into();
assert_eq!(session.run(capture.clone(), &[]).error, None);
session.retire_changed_icons(|_| false);
assert!(session.run(capture, &[]).error.is_some());
}
#[test]
fn history_retires_icons_without_discarding_the_document_globals() {
let mut session = ScriptSession::default();
let mut capture = job("var oldIcon = getField('go').buttonGetIcon(); buffer='retained';");
Arc::make_mut(&mut capture.fields)[0].icon = Some(17);
assert_eq!(session.run(capture.clone(), &[]).error, None);
capture.script = "getField('go').buttonSetIcon(oldIcon);".into();
assert_eq!(session.run(capture.clone(), &[]).error, None);
session.retire_changed_icons(|id| id == 18);
assert_eq!(session.run(capture.clone(), &[]).error, None);
session.retire_changed_icons(|id| id == 17);
assert!(session.run(capture.clone(), &[]).error.is_some());
capture.script = "buffer;".into();
assert_eq!(session.run(capture, &[]).result.as_deref(), Some("retained"));
}
/// Exercise the actual process-wide cap in isolation, then recover a slot without resetting other documents.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn document_worker_capacity_is_bounded_and_reusable() {
const CHILD: &str = "PDFCRAFT_WORKER_CAPACITY_CHILD";
if std::env::var_os(CHILD).is_none() {
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "session::tests::document_worker_capacity_is_bounded_and_reusable"])
.env(CHILD, "1")
.output()
.unwrap();
assert!(out.status.success(), "{} {}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
return;
}
let mut workers = Vec::new();
for _ in 0..MAX_DOCUMENT_WORKERS {
let mut worker = ScriptSession::default();
assert_eq!(worker.run(job("digit('1')"), &[]).result.as_deref(), Some("1"));
workers.push(worker);
}
let mut extra = ScriptSession::default();
assert!(extra.start(job("1")).unwrap_err().contains("worker limit"));
drop(workers.pop());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while LIVE_WORKERS.load(std::sync::atomic::Ordering::SeqCst) == MAX_DOCUMENT_WORKERS {
assert!(std::time::Instant::now() < deadline);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(extra.run(job("digit('2')"), &[]).result.as_deref(), Some("2"));
for worker in &mut workers {
assert_eq!(worker.run(job("digit('3')"), &[]).result.as_deref(), Some("13"));
}
}
/// Reset effects are visible during Doc Open and to the first event, including retained Field objects.
#[test]
fn doc_open_reset_precedes_the_first_event_and_later_writes_win() {
for reset in ["resetForm();", "resetForm(['group']);"] {
let mut source = FieldState::new("group.source", crate::FieldType::Text, vec!["old".into()]);
source.default = vec!["new".into()];
let dest = FieldState::new("dest", crate::FieldType::Text, vec!["before".into()]);
let mut task = job("getField('dest').value=held.value;held.value='old';openedValue+'/'+getField('dest').value");
task.fields = Arc::new(vec![source, dest]);
task.doc_scripts = vec![format!("var held=getField('group.source');{reset}var openedValue=held.value;")];
let out = ScriptSession::default().run(task, &[]);
assert_eq!(out.error, None);
assert_eq!(out.result.as_deref(), Some("new/new"));
assert_eq!(out.changed.iter().find(|f| f.name == "group.source").unwrap().value, ["old"]);
assert_eq!(out.changed.iter().find(|f| f.name == "dest").unwrap().value, ["new"]);
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod reset_budget_tests {
use super::*;
#[test]
fn open_and_first_event_share_reset_budget_but_later_jobs_reset_it() {
let mut runtime = ScriptSession::default();
let mut job = Job {
script: "resetForm();".into(),
event: Event::doc("Console"),
doc: Default::default(),
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);
assert_eq!(out.requests.iter().filter(|r| matches!(r, crate::Request::Reset(_))).count(), 64);
job.script = "resetForm();true;".into();
let out = runtime.run(job, &[]);
assert!(!out.reset_limit_exceeded);
assert_eq!(out.error, None);
assert_eq!(out.result.as_deref(), Some("true"));
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod event_identity_tests {
use super::*;
#[test]
fn ordinary_button_event_target_reuses_public_field_object() {
let mut field = FieldState::new("go", crate::FieldType::Button, vec![]);
field.object_id = Some(17);
let job = Job {
script: "event.target===getField('go');".into(),
event: Event::field("Mouse Up", "go", ""),
doc: Default::default(),
fields: Arc::new(vec![field]),
doc_scripts: vec![],
calculations: vec![],
timer: None,
};
let out = ScriptSession::default().run(job, &[]);
assert_eq!(out.error, None);
assert_eq!(out.result.as_deref(), Some("true"));
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod cache_capacity_tests {
use super::*;
#[test]
fn every_exposed_field_keeps_identity_beyond_4096_entries() {
let fields = Arc::new(
(0..4100)
.map(|i| {
let mut f = FieldState::new(format!("f{i}"), crate::FieldType::Text, vec![]);
f.object_id = Some(i);
f
})
.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![],timer:None};
let mut session = ScriptSession::default();
let out = session.run(job.clone(), &[]);
assert_eq!(out.error, None);
assert_eq!(out.result.as_deref(), Some("true"));
let out = session.run(Job { script: "held===getField('f4099') && held.custom===42;".into(), ..job }, &[]);
assert_eq!(out.result.as_deref(), Some("true"));
}
}
#[cfg(test)]
mod cache_lifecycle_tests {
use super::*;
#[test]
fn cache_follows_object_renames_and_prunes_removed_fields() {
let mut field = FieldState::new("old", crate::FieldType::Text, vec![]);
field.object_id = Some(17);
let mut job = Job {
script: "var held=getField('old');held.custom=42;".into(),
event: Event::doc("Console"),
doc: Default::default(),
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);
for i in 0..16 {
field.name = format!("renamed{i}");
job.fields = Arc::new(vec![field.clone()]);
job.script = format!("held.setFocus();held===getField('{}') && held.name==='{}' && held.custom===42;", field.name, field.name);
let out = realm.run(job.clone());
assert_eq!(out.error, None);
assert_eq!(out.result.as_deref(), Some("true"));
assert_eq!(out.requests, [crate::Request::FocusField { name: field.name.clone(), object_id: 17 }]);
assert_eq!(crate::host::host(realm.context.as_ref().unwrap()).unwrap().borrow().objects.len(), 1);
}
field.object_id = Some(18);
field.name = "replacement".into();
job.fields = Arc::new(vec![field]);
job.script = "getField('replacement');".into();
assert_eq!(realm.run(job.clone()).error, None);
assert_eq!(crate::host::host(realm.context.as_ref().unwrap()).unwrap().borrow().objects.len(), 1);
job.script = "held.value;".into();
assert!(realm.run(job).error.is_some());
}
}
#[cfg(test)]
mod review13_tests {
use super::*;
use crate::FieldType;
/// Build isolated jobs without document initialization side effects.
fn job(script: &str, fields: Vec<FieldState>) -> Job {
Job {
script: script.into(),
event: Event::doc("Test"),
doc: DocInfo::default(),
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.
#[test]
fn field_instances_retire_between_jobs_without_losing_globals() {
let mut session = ScriptSession::default();
let mut f = FieldState::new("old", FieldType::Text, vec!["original".into()]);
f.object_id = Some(7);
assert_eq!(session.run(job("var held=getField('old');held.custom=42;var kept=99;", vec![f.clone()]), &[]).error, None);
session.sync_field_ids([]);
session.sync_field_ids([7]);
f.name = "replacement".into();
let out =
session.run(job("kept===99 && getField('replacement')!==held && getField('replacement').custom===undefined;", vec![f.clone()]), &[]);
assert_eq!(out.result.as_deref(), Some("true"));
for script in ["held.value;", "held.value='wrong';", "held.setFocus();"] {
let out = session.run(job(script, vec![f.clone()]), &[]);
assert!(out.error.is_some());
assert!(out.changed.is_empty());
}
}
/// Frozen globals still expose each new event and permit nested calculation event restoration.
#[test]
fn frozen_global_keeps_live_event_binding() {
let mut session = ScriptSession::default();
let f = FieldState::new("amount", FieldType::Text, vec!["2".into()]);
let mut first = job("event.name", vec![f.clone()]);
first.doc_scripts = vec!["Object.freeze(globalThis);".into()];
assert_eq!(session.run(first.clone(), &[]).result.as_deref(), Some("Test"));
first.event = Event::doc("Next");
first.script = "calculateNow();event.name".into();
first.calculations = vec![(0, "event.value='3';".into())];
let out = session.run(first, &[]);
assert_eq!(out.error, None);
assert_eq!(out.result.as_deref(), Some("Next"));
assert_eq!(out.changed[0].value, ["3"]);
}
/// Calculation values follow the existing setter normalization, including unchecked empty boxes.
#[test]
fn calculate_now_normalizes_checkbox_but_preserves_text_and_combo_empty_values() {
let mut session = ScriptSession::default();
let fields = vec![
FieldState::new("box", FieldType::CheckBox, vec!["Yes".into()]),
FieldState::new("text", FieldType::Text, vec!["old".into()]),
FieldState::new("combo", FieldType::ComboBox, vec!["old".into()]),
];
let mut j = job("calculateNow();getField('box').value", fields);
j.calculations = (0..3).map(|i| (i, "event.value='';".into())).collect();
let out = session.run(j, &[]);
assert_eq!(out.error, None);
assert_eq!(out.result.as_deref(), Some("Off"));
assert!(out.changed[0].value.is_empty());
assert_eq!(out.changed[1].value, [""]);
assert_eq!(out.changed[2].value, [""]);
}
/// Small individual lookups cannot multiply unbounded work across calculation sources.
#[test]
fn calculation_sources_share_one_job_budget_and_later_jobs_reset_it() {
let mut session = ScriptSession::default();
let fields: Vec<_> = (0..6).map(|i| FieldState::new(format!("f{i}"), FieldType::Text, vec!["1".into()])).collect();
let mut j = job("calculateNow();", fields.clone());
j.calculations = (0..6).map(|i| (i, "for(var q=0;q<3;q++)AFSimple_Calculate('SUM',new Array(1024).fill('x'.repeat(256)));".into())).collect();
let out = session.run(j, &[]);
assert!(out.error.as_deref().is_some_and(|e| e.contains("work limit")), "{:?}", out.error);
assert_eq!(session.run(job("AFSimple_Calculate('SUM',['f0']);", fields), &[]).result.as_deref(), Some("1"));
}
#[test]
fn calculate_now_continues_after_an_ordinary_field_error() {
let mut session = ScriptSession::default();
let fields = ["bad", "good"].map(|name| FieldState::new(name, FieldType::Text, vec!["0".into()])).to_vec();
let mut j = job("calculateNow(); event.name + ':' + getField('good').value", fields);
j.calculations = vec![(0, "throw new Error('broken formula');".into()), (1, "event.value='42';".into())];
let out = session.run(j, &[]);
assert_eq!(out.error, None);
assert_eq!(out.result.as_deref(), Some("Test:42"));
assert!(out.console.iter().any(|line| line.contains("broken formula")));
}
#[test]
fn caught_calculation_exhaustion_stays_sticky_until_the_next_job() {
let mut session = ScriptSession::default();
let fields = vec![FieldState::new("amount", FieldType::Text, vec!["0".into()])];
let mut j = job("for(var i=0;i<4097;i++){try{calculateNow()}catch(e){}} try{calculateNow()}catch(e){} count;", fields.clone());
j.calculations = vec![(0, "count += 1;".into())];
j.doc_scripts = vec!["var count = 0;".into()];
let out = session.run(j.clone(), &[]);
assert!(out.calculation_limit_exceeded);
assert!(out.error.as_deref().is_some_and(|e| e.contains("calculation work limit")));
assert_eq!(out.result.as_deref(), Some("4096"));
j.script = "calculateNow();count".into();
let next = session.run(j, &[]);
assert!(!next.calculation_limit_exceeded);
assert_eq!(next.result.as_deref(), Some("4097"));
}
}