Skip to main content

engine_observables_api/
quiescence.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5//! Quiescence: the report a surface makes of its pending work, and the default
6//! policy for "settled".
7//!
8//! Every automation harness that polls-and-sleeps does so because it cannot ask
9//! the engine what is still in flight. The engine can answer per source, and the
10//! per-source signals already exist — this module adds no mechanism, only the
11//! common vocabulary the host uses to join them:
12//!
13//! - loads: `LoadingState` (this crate) — terminal at `Done` / `Failed`
14//! - script microtasks: `script-engine-api`'s `pump` (`Quiescent` / `Pending`)
15//! - timers: the runtime's `next_timer_delay()`
16//! - animation-frame callbacks: the runtime's `has_animation_frame_callbacks()`
17//! - declared animations: layout's `has_active_animations()`
18//!
19//! Only the host sees all of these at once, so the host assembles a
20//! [`PendingWork`] per surface; consumers (a test harness, a WebDriver adapter,
21//! an agent's observation) read it instead of sleeping.
22//!
23//! ## Settling vs perpetual sources
24//!
25//! The default [`settled`](PendingWork::settled) policy counts only work that
26//! *finishes by itself*: loads, microtasks, and dirty layout. The other sources
27//! are reported but do not block, each for a stated reason:
28//!
29//! - **Timers.** A page with `setTimeout(fn, 30_000)` is not "busy" for those
30//!   thirty seconds. WebDriver's own document-readiness never waits on timers,
31//!   and a harness that did would turn every long-poll page into a hang.
32//! - **Animation-frame callbacks.** A one-shot rAF (schedule a measurement)
33//!   settles next frame, but a rAF *loop* — every game loop, every physics
34//!   surface — re-requests forever, and the two are statically
35//!   indistinguishable. Blocking on rAF turns "the orrery is breathing" into
36//!   "the harness never returns".
37//! - **Declared animations.** CSS transitions/animations run on the engine's
38//!   clock and may be infinite (`animation-iteration-count: infinite`).
39//!
40//! For those, the tool is a condition-wait ("element exists", "attribute
41//! equals"), not a broader settle. A caller with cause to block on the
42//! perpetual sources opts in explicitly via [`fully_idle`](PendingWork::fully_idle)
43//! and owns the hang risk it is accepting.
44
45use malloc_size_of_derive::MallocSizeOf;
46use serde::{Deserialize, Serialize};
47
48/// One surface's pending work, by source, at the moment of asking.
49///
50/// A snapshot, not a subscription: settling is level-triggered (ask again), so a
51/// harness loop is "apply step, ask until settled, assert" with no sleep in it.
52#[derive(Clone, Copy, Debug, Default, Deserialize, MallocSizeOf, PartialEq, Serialize)]
53pub struct PendingWork {
54    /// In-flight loads (document, subresources) on this surface.
55    pub loads: usize,
56    /// The script engine's job queue is non-empty (`pump` would report
57    /// `Pending`). `false` where the surface runs no script.
58    pub microtasks: bool,
59    /// Layout or paint work is scheduled but not yet performed.
60    pub layout_dirty: bool,
61    /// Delay to the next scheduled timer, in ms, if any timer is scheduled.
62    /// Reported, never blocking (see module docs).
63    pub next_timer_ms: Option<f64>,
64    /// Animation-frame callbacks are requested for the next frame. Reported,
65    /// never blocking: a rAF loop is indistinguishable from a one-shot.
66    pub animation_frames: bool,
67    /// Declared CSS transitions/animations are running. Reported, never
68    /// blocking: they may be infinite.
69    pub animations: bool,
70}
71
72impl PendingWork {
73    /// The default settle policy: every source that finishes by itself is done.
74    ///
75    /// This is the harness's "the step landed" signal — loads complete, script
76    /// quiescent, layout clean. It deliberately ignores timers, rAF and declared
77    /// animations; see the module docs for why each would turn a live surface
78    /// into a hang.
79    pub fn settled(&self) -> bool {
80        self.loads == 0 && !self.microtasks && !self.layout_dirty
81    }
82
83    /// Everything idle, the perpetual-capable sources included. Meaningful on a
84    /// genuinely static surface; on anything animated this may simply never be
85    /// true, which is the caller's risk to accept.
86    pub fn fully_idle(&self) -> bool {
87        self.settled()
88            && self.next_timer_ms.is_none()
89            && !self.animation_frames
90            && !self.animations
91    }
92}
93
94/// Common-minimum quiescence query, per surface. Implemented host-side (only the
95/// host sees loads, script, and layout at once); read by harnesses, protocol
96/// adapters, and agent observation assembly.
97pub trait QuiescenceQuery {
98    /// The surface's pending work right now.
99    fn pending_work(&mut self) -> PendingWork;
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn settled_ignores_the_perpetual_sources() {
108        // The orrery shape: nothing finishing, everything breathing.
109        let breathing = PendingWork {
110            next_timer_ms: Some(400.0),
111            animation_frames: true,
112            animations: true,
113            ..PendingWork::default()
114        };
115        assert!(
116            breathing.settled(),
117            "a surface that is only animating is settled, or every game loop hangs the harness"
118        );
119        assert!(!breathing.fully_idle());
120    }
121
122    #[test]
123    fn settled_blocks_on_work_that_finishes() {
124        for pending in [
125            PendingWork {
126                loads: 1,
127                ..PendingWork::default()
128            },
129            PendingWork {
130                microtasks: true,
131                ..PendingWork::default()
132            },
133            PendingWork {
134                layout_dirty: true,
135                ..PendingWork::default()
136            },
137        ] {
138            assert!(!pending.settled(), "{pending:?} must block settle");
139        }
140        assert!(PendingWork::default().settled());
141        assert!(PendingWork::default().fully_idle());
142    }
143}