pdfrum_common/deadline.rs
1//! When work must stop: a flag a host raises, and — where the target has a
2//! clock — a budget that raises it for you.
3//!
4//! The engine has no threads to interrupt and no signal to catch: a deadline
5//! is honoured by the loops that already have a natural granularity — the
6//! content interpreter per batch of operators, the rasterizer per object, the
7//! extractor and the page loader per page, the cross-reference rebuild per
8//! chunk of tokens, the script engine per run — each asking
9//! [`Deadline::passed`] at that boundary and stopping. The reads are cheap
10//! (one atomic load, and one `Instant::now()` only while a budget is set)
11//! and happen only when a deadline is set; the default path pays one branch.
12//!
13//! **The flag is the mechanism and the clock is a convenience**, because
14//! `std::time::Instant::now()` aborts at runtime on `wasm32-unknown-unknown`
15//! and the facade builds for that target. A host there stops the work from
16//! its own timer or event with [`Deadline::stop`]; a native host usually
17//! writes [`Deadline::after`], which is the same flag plus a clock read at
18//! every check.
19
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::time::Duration;
23use std::time::Instant;
24
25use crate::{LimitExceeded, PageIndex};
26
27/// When work must stop. Cloning shares the flag: a host keeps one clone to
28/// raise and hands the other to [`Limits`](crate::Limits).
29///
30/// ```
31/// use pdfrum_common::Deadline;
32///
33/// // The mechanism: a flag raised from anywhere, on every target.
34/// let stop = Deadline::manual();
35/// let limit = stop.clone();
36/// assert!(!limit.passed());
37/// stop.stop();
38/// assert!(limit.passed());
39/// ```
40///
41/// ```
42/// # #[cfg(not(target_arch = "wasm32"))] {
43/// use std::time::Duration;
44/// use pdfrum_common::Deadline;
45///
46/// // The convenience, where there is a clock: a budget from now.
47/// let deadline = Deadline::after(Duration::from_secs(5));
48/// assert!(!deadline.passed());
49/// assert_eq!(deadline.budget(), Some(Duration::from_secs(5)));
50///
51/// // A zero budget has passed before it is asked.
52/// assert!(Deadline::after(Duration::ZERO).passed());
53/// # }
54/// ```
55#[derive(Debug, Clone)]
56pub struct Deadline {
57 /// Raised by [`Deadline::stop`]. Shared by every clone.
58 stop: Arc<AtomicBool>,
59 /// The budget, where one was set. Always `None` on `wasm32`, whose
60 /// clock constructors do not exist, so no code path there reads a clock.
61 clock: Option<Clock>,
62}
63
64/// A budget counted from the moment it was made. Stored as a start and a
65/// budget rather than as one `Instant`, so the message can say how much
66/// time was allowed and so no arithmetic on `Instant` can overflow.
67#[derive(Debug, Clone, Copy)]
68struct Clock {
69 start: Instant,
70 budget: Duration,
71}
72
73impl Deadline {
74 /// A deadline nothing but [`Deadline::stop`] raises.
75 #[must_use]
76 pub fn manual() -> Deadline {
77 Deadline {
78 stop: Arc::new(AtomicBool::new(false)),
79 clock: None,
80 }
81 }
82
83 /// A deadline `budget` from now. It can still be raised early with
84 /// [`Deadline::stop`].
85 ///
86 /// Not available on `wasm32`, which has no monotonic clock to read: a
87 /// host there uses [`Deadline::manual`] and its own timer.
88 #[cfg(not(target_arch = "wasm32"))]
89 #[must_use]
90 pub fn after(budget: Duration) -> Deadline {
91 Deadline {
92 stop: Arc::new(AtomicBool::new(false)),
93 clock: Some(Clock {
94 start: Instant::now(),
95 budget,
96 }),
97 }
98 }
99
100 /// This deadline's flag, with a budget of `budget` from now.
101 ///
102 /// The returned deadline **shares the flag**, so [`Deadline::stop`] on
103 /// either raises both — it is the same deadline with a clock added, not a
104 /// second one beside it. That is what a host wants when it holds a cancel
105 /// flag *and* a wall-clock budget: one deadline that answers to whichever
106 /// arrives first. Building the two separately gives the engine only one of
107 /// them, because [`Limits::deadline`](crate::Limits::deadline) is a single
108 /// slot.
109 ///
110 /// An existing budget is replaced, not intersected.
111 ///
112 /// Not available on `wasm32`, as [`Deadline::after`].
113 ///
114 /// ```
115 /// # #[cfg(not(target_arch = "wasm32"))] {
116 /// use std::time::Duration;
117 /// use pdfrum_common::Deadline;
118 ///
119 /// let flag = Deadline::manual();
120 /// let limit = flag.with_budget(Duration::from_secs(30));
121 ///
122 /// // The budget is live on the copy the engine holds...
123 /// assert_eq!(limit.budget(), Some(Duration::from_secs(30)));
124 /// assert!(!limit.passed());
125 ///
126 /// // ...and the flag still reaches it.
127 /// flag.stop();
128 /// assert!(limit.passed());
129 /// # }
130 /// ```
131 #[cfg(not(target_arch = "wasm32"))]
132 #[must_use]
133 pub fn with_budget(&self, budget: Duration) -> Deadline {
134 Deadline {
135 stop: Arc::clone(&self.stop),
136 clock: Some(Clock {
137 start: Instant::now(),
138 budget,
139 }),
140 }
141 }
142
143 /// The deadline at `instant`; one already in the past has passed.
144 ///
145 /// Not available on `wasm32`, as [`Deadline::after`].
146 #[cfg(not(target_arch = "wasm32"))]
147 #[must_use]
148 pub fn at(instant: Instant) -> Deadline {
149 let start = Instant::now();
150 Deadline {
151 stop: Arc::new(AtomicBool::new(false)),
152 clock: Some(Clock {
153 start,
154 budget: instant.saturating_duration_since(start),
155 }),
156 }
157 }
158
159 /// Raises the flag: every check from now on, on every clone, answers
160 /// that the deadline has passed. Idempotent, and callable from any
161 /// thread.
162 pub fn stop(&self) {
163 self.stop.store(true, Ordering::Relaxed);
164 }
165
166 /// How much time was allowed, for a deadline made with a budget.
167 #[must_use]
168 pub fn budget(&self) -> Option<Duration> {
169 self.clock.map(|clock| clock.budget)
170 }
171
172 /// Whether the flag is raised or the budget spent. One atomic load, and
173 /// a clock read only while the flag is down and a budget is set.
174 #[must_use]
175 pub fn passed(&self) -> bool {
176 self.stop.load(Ordering::Relaxed) || self.spent_budget().is_some()
177 }
178
179 /// `Ok` while the budget lasts and the flag is down; past either, the
180 /// error that names what was being done. The page, when there is one,
181 /// is the caller's to add with [`LimitExceeded::on_page`].
182 ///
183 /// # Errors
184 ///
185 /// [`LimitExceeded::Stopped`] once [`Deadline::stop`] was called,
186 /// [`LimitExceeded::Time`] once a budget is spent.
187 pub fn check(&self, during: Operation) -> Result<(), LimitExceeded> {
188 if self.stop.load(Ordering::Relaxed) {
189 return Err(LimitExceeded::Stopped { during, page: None });
190 }
191 match self.spent_budget() {
192 Some(budget) => Err(LimitExceeded::Time {
193 budget,
194 during,
195 page: None,
196 }),
197 None => Ok(()),
198 }
199 }
200
201 /// The budget, if there is one and it is spent. On `wasm32` there is
202 /// never one, so only the flag can pass a deadline there.
203 fn spent_budget(&self) -> Option<Duration> {
204 self.clock
205 .filter(|clock| clock.start.elapsed() >= clock.budget)
206 .map(|clock| clock.budget)
207 }
208}
209
210/// Two deadlines are equal when they share a flag — clones of one another —
211/// which is the question a caller comparing two `Limits` is asking.
212impl PartialEq for Deadline {
213 fn eq(&self, other: &Deadline) -> bool {
214 Arc::ptr_eq(&self.stop, &other.stop)
215 }
216}
217
218impl Eq for Deadline {}
219
220/// What the engine was doing when a deadline passed — the noun in the
221/// message, and the boundary at which the check sits.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
223#[non_exhaustive]
224pub enum Operation {
225 /// Opening the document: the header and cross-reference read, including
226 /// the rebuild scan of a damaged file.
227 Open,
228 /// Loading one page's dictionary — the per-page boundary a walk over a
229 /// document crosses.
230 PageLoad,
231 /// Interpreting a content stream into page objects.
232 Interpret,
233 /// Rasterizing the page objects.
234 Render,
235 /// Extracting text.
236 Extract,
237 /// Running a document script.
238 Script,
239}
240
241impl Operation {
242 /// The message's verb phrase, with the page where one is known.
243 pub(crate) fn describe(self, page: Option<PageIndex>) -> String {
244 let what = match self {
245 Operation::Open => "opening the document",
246 Operation::PageLoad => "loading page",
247 Operation::Interpret => "interpreting page",
248 Operation::Render => "rendering page",
249 Operation::Extract => "extracting text from page",
250 Operation::Script => "running a script",
251 };
252 match (self, page) {
253 (Operation::Open | Operation::Script, _) | (_, None) => what.to_string(),
254 (_, Some(page)) => format!("{what} {page}"),
255 }
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::{Deadline, Operation};
262 use crate::{LimitExceeded, PageIndex};
263 use std::time::{Duration, Instant};
264
265 #[test]
266 fn a_manual_deadline_passes_only_when_stopped_and_every_clone_sees_it() {
267 let stop = Deadline::manual();
268 let held = stop.clone();
269 assert!(!held.passed());
270 assert_eq!(held.check(Operation::Open), Ok(()));
271 assert_eq!(held.budget(), None);
272 stop.stop();
273 assert!(held.passed());
274 assert_eq!(
275 held.check(Operation::Render),
276 Err(LimitExceeded::Stopped {
277 during: Operation::Render,
278 page: None,
279 })
280 );
281 // Idempotent.
282 stop.stop();
283 assert!(stop.passed());
284 }
285
286 #[test]
287 fn equality_is_sharing_a_flag() {
288 let a = Deadline::manual();
289 assert_eq!(a, a.clone());
290 assert_ne!(a, Deadline::manual());
291 }
292
293 #[test]
294 fn a_zero_budget_has_passed_and_an_hour_has_not() {
295 assert!(Deadline::after(Duration::ZERO).passed());
296 assert!(!Deadline::after(Duration::from_hours(1)).passed());
297 }
298
299 #[test]
300 fn a_budget_can_still_be_stopped_early() {
301 let deadline = Deadline::after(Duration::from_hours(1));
302 deadline.stop();
303 assert!(deadline.passed());
304 assert!(matches!(
305 deadline.check(Operation::Extract),
306 Err(LimitExceeded::Stopped { .. })
307 ));
308 }
309
310 #[test]
311 fn an_instant_already_reached_has_passed() {
312 let now = Instant::now();
313 let deadline = Deadline::at(now);
314 assert!(deadline.passed());
315 assert_eq!(deadline.budget(), Some(Duration::ZERO));
316 let later = Deadline::at(now + Duration::from_hours(1));
317 assert!(!later.passed());
318 assert!(later.budget() > Some(Duration::from_secs(3599)));
319 }
320
321 #[test]
322 fn the_check_names_the_operation_and_leaves_the_page_to_the_caller() {
323 let deadline = Deadline::after(Duration::from_secs(5));
324 assert_eq!(deadline.check(Operation::Render), Ok(()));
325 let spent = Deadline::after(Duration::ZERO);
326 let error = spent.check(Operation::Render).expect_err("spent");
327 assert_eq!(
328 error,
329 LimitExceeded::Time {
330 budget: Duration::ZERO,
331 during: Operation::Render,
332 page: None,
333 }
334 );
335 assert_eq!(
336 error.on_page(PageIndex::from(2u32)),
337 LimitExceeded::Time {
338 budget: Duration::ZERO,
339 during: Operation::Render,
340 page: Some(PageIndex::from(2u32)),
341 }
342 );
343 }
344
345 #[test]
346 fn descriptions_take_a_page_only_where_one_makes_sense() {
347 let page = Some(PageIndex::from(3u32));
348 assert_eq!(Operation::Open.describe(page), "opening the document");
349 assert_eq!(Operation::Script.describe(page), "running a script");
350 assert_eq!(Operation::Render.describe(page), "rendering page 3");
351 assert_eq!(Operation::Render.describe(None), "rendering page");
352 assert_eq!(Operation::PageLoad.describe(page), "loading page 3");
353 assert_eq!(Operation::Interpret.describe(page), "interpreting page 3");
354 assert_eq!(
355 Operation::Extract.describe(page),
356 "extracting text from page 3"
357 );
358 }
359}