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 that answers to a flag the caller already owns.
84 ///
85 /// Raising `flag` (`store(true, ..)`, from any thread) makes this
86 /// deadline, and every clone of it, pass, exactly as [`Deadline::stop`]
87 /// does; [`Deadline::stop`] on it raises `flag` too. A host with one
88 /// cancellation flag per job hands that flag here rather than keeping a
89 /// second one in step with it. Add a budget with
90 /// [`Deadline::with_budget`].
91 ///
92 /// The flag is only read, with a relaxed load. Nothing lowers it again,
93 /// so a flag that is reused must be replaced with a fresh one.
94 ///
95 /// ```
96 /// use std::sync::Arc;
97 /// use std::sync::atomic::{AtomicBool, Ordering};
98 /// use pdfrum_common::Deadline;
99 ///
100 /// let flag = Arc::new(AtomicBool::new(false));
101 /// let deadline = Deadline::from_flag(Arc::clone(&flag));
102 /// assert!(!deadline.passed());
103 /// flag.store(true, Ordering::Relaxed);
104 /// assert!(deadline.passed());
105 /// ```
106 #[must_use]
107 pub fn from_flag(flag: Arc<AtomicBool>) -> Deadline {
108 Deadline {
109 stop: flag,
110 clock: None,
111 }
112 }
113
114 /// A deadline `budget` from now. It can still be raised early with
115 /// [`Deadline::stop`].
116 ///
117 /// Not available on `wasm32`, which has no monotonic clock to read: a
118 /// host there uses [`Deadline::manual`] and its own timer.
119 #[cfg(not(target_arch = "wasm32"))]
120 #[must_use]
121 pub fn after(budget: Duration) -> Deadline {
122 Deadline {
123 stop: Arc::new(AtomicBool::new(false)),
124 clock: Some(Clock {
125 start: Instant::now(),
126 budget,
127 }),
128 }
129 }
130
131 /// This deadline's flag, with a budget of `budget` from now.
132 ///
133 /// The returned deadline **shares the flag**, so [`Deadline::stop`] on
134 /// either raises both — it is the same deadline with a clock added, not a
135 /// second one beside it. That is what a host wants when it holds a cancel
136 /// flag *and* a wall-clock budget: one deadline that answers to whichever
137 /// arrives first. Building the two separately gives the engine only one of
138 /// them, because [`Limits::deadline`](crate::Limits::deadline) is a single
139 /// slot.
140 ///
141 /// An existing budget is replaced, not intersected.
142 ///
143 /// Not available on `wasm32`, as [`Deadline::after`].
144 ///
145 /// ```
146 /// # #[cfg(not(target_arch = "wasm32"))] {
147 /// use std::time::Duration;
148 /// use pdfrum_common::Deadline;
149 ///
150 /// let flag = Deadline::manual();
151 /// let limit = flag.with_budget(Duration::from_secs(30));
152 ///
153 /// // The budget is live on the copy the engine holds...
154 /// assert_eq!(limit.budget(), Some(Duration::from_secs(30)));
155 /// assert!(!limit.passed());
156 ///
157 /// // ...and the flag still reaches it.
158 /// flag.stop();
159 /// assert!(limit.passed());
160 /// # }
161 /// ```
162 #[cfg(not(target_arch = "wasm32"))]
163 #[must_use]
164 pub fn with_budget(&self, budget: Duration) -> Deadline {
165 Deadline {
166 stop: Arc::clone(&self.stop),
167 clock: Some(Clock {
168 start: Instant::now(),
169 budget,
170 }),
171 }
172 }
173
174 /// The deadline at `instant`; one already in the past has passed.
175 ///
176 /// Not available on `wasm32`, as [`Deadline::after`].
177 #[cfg(not(target_arch = "wasm32"))]
178 #[must_use]
179 pub fn at(instant: Instant) -> Deadline {
180 let start = Instant::now();
181 Deadline {
182 stop: Arc::new(AtomicBool::new(false)),
183 clock: Some(Clock {
184 start,
185 budget: instant.saturating_duration_since(start),
186 }),
187 }
188 }
189
190 /// Raises the flag: every check from now on, on every clone, answers
191 /// that the deadline has passed. Idempotent, and callable from any
192 /// thread.
193 pub fn stop(&self) {
194 self.stop.store(true, Ordering::Relaxed);
195 }
196
197 /// How much time was allowed, for a deadline made with a budget.
198 #[must_use]
199 pub fn budget(&self) -> Option<Duration> {
200 self.clock.map(|clock| clock.budget)
201 }
202
203 /// Whether the flag is raised or the budget spent. One atomic load, and
204 /// a clock read only while the flag is down and a budget is set.
205 #[must_use]
206 pub fn passed(&self) -> bool {
207 self.stop.load(Ordering::Relaxed) || self.spent_budget().is_some()
208 }
209
210 /// `Ok` while the budget lasts and the flag is down; past either, the
211 /// error that names what was being done. The page, when there is one,
212 /// is the caller's to add with [`LimitExceeded::on_page`].
213 ///
214 /// # Errors
215 ///
216 /// [`LimitExceeded::Stopped`] once [`Deadline::stop`] was called,
217 /// [`LimitExceeded::Time`] once a budget is spent.
218 pub fn check(&self, during: Operation) -> Result<(), LimitExceeded> {
219 if self.stop.load(Ordering::Relaxed) {
220 return Err(LimitExceeded::Stopped { during, page: None });
221 }
222 match self.spent_budget() {
223 Some(budget) => Err(LimitExceeded::Time {
224 budget,
225 during,
226 page: None,
227 }),
228 None => Ok(()),
229 }
230 }
231
232 /// The budget, if there is one and it is spent. On `wasm32` there is
233 /// never one, so only the flag can pass a deadline there.
234 fn spent_budget(&self) -> Option<Duration> {
235 self.clock
236 .filter(|clock| clock.start.elapsed() >= clock.budget)
237 .map(|clock| clock.budget)
238 }
239}
240
241/// Two deadlines are equal when they share a flag — clones of one another —
242/// which is the question a caller comparing two `Limits` is asking.
243impl PartialEq for Deadline {
244 fn eq(&self, other: &Deadline) -> bool {
245 Arc::ptr_eq(&self.stop, &other.stop)
246 }
247}
248
249impl Eq for Deadline {}
250
251/// What the engine was doing when a deadline passed — the noun in the
252/// message, and the boundary at which the check sits.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
254#[non_exhaustive]
255pub enum Operation {
256 /// Opening the document: the header and cross-reference read, including
257 /// the rebuild scan of a damaged file.
258 Open,
259 /// Loading one page's dictionary — the per-page boundary a walk over a
260 /// document crosses.
261 PageLoad,
262 /// Interpreting a content stream into page objects.
263 Interpret,
264 /// Rasterizing the page objects.
265 Render,
266 /// Extracting text.
267 Extract,
268 /// Running a document script.
269 Script,
270}
271
272impl Operation {
273 /// The message's verb phrase, with the page where one is known.
274 pub(crate) fn describe(self, page: Option<PageIndex>) -> String {
275 let what = match self {
276 Operation::Open => "opening the document",
277 Operation::PageLoad => "loading page",
278 Operation::Interpret => "interpreting page",
279 Operation::Render => "rendering page",
280 Operation::Extract => "extracting text from page",
281 Operation::Script => "running a script",
282 };
283 match (self, page) {
284 (Operation::Open | Operation::Script, _) | (_, None) => what.to_string(),
285 (_, Some(page)) => format!("{what} {page}"),
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::{Deadline, Operation};
293 use crate::{LimitExceeded, PageIndex};
294 use std::time::{Duration, Instant};
295
296 #[test]
297 fn a_manual_deadline_passes_only_when_stopped_and_every_clone_sees_it() {
298 let stop = Deadline::manual();
299 let held = stop.clone();
300 assert!(!held.passed());
301 assert_eq!(held.check(Operation::Open), Ok(()));
302 assert_eq!(held.budget(), None);
303 stop.stop();
304 assert!(held.passed());
305 assert_eq!(
306 held.check(Operation::Render),
307 Err(LimitExceeded::Stopped {
308 during: Operation::Render,
309 page: None,
310 })
311 );
312 // Idempotent.
313 stop.stop();
314 assert!(stop.passed());
315 }
316
317 #[test]
318 fn a_deadline_from_a_flag_passes_when_the_flag_is_raised_and_raises_it_when_stopped() {
319 use std::sync::Arc;
320 use std::sync::atomic::{AtomicBool, Ordering};
321 let flag = Arc::new(AtomicBool::new(false));
322 let deadline = Deadline::from_flag(Arc::clone(&flag));
323 assert!(!deadline.passed());
324 assert_eq!(deadline.budget(), None);
325 flag.store(true, Ordering::Relaxed);
326 assert!(matches!(
327 deadline.check(Operation::Render),
328 Err(LimitExceeded::Stopped { .. })
329 ));
330 let other = Arc::new(AtomicBool::new(false));
331 Deadline::from_flag(Arc::clone(&other)).stop();
332 assert!(other.load(Ordering::Relaxed));
333 }
334
335 #[test]
336 fn equality_is_sharing_a_flag() {
337 let a = Deadline::manual();
338 assert_eq!(a, a.clone());
339 assert_ne!(a, Deadline::manual());
340 }
341
342 #[test]
343 fn a_zero_budget_has_passed_and_an_hour_has_not() {
344 assert!(Deadline::after(Duration::ZERO).passed());
345 assert!(!Deadline::after(Duration::from_hours(1)).passed());
346 }
347
348 #[test]
349 fn a_budget_can_still_be_stopped_early() {
350 let deadline = Deadline::after(Duration::from_hours(1));
351 deadline.stop();
352 assert!(deadline.passed());
353 assert!(matches!(
354 deadline.check(Operation::Extract),
355 Err(LimitExceeded::Stopped { .. })
356 ));
357 }
358
359 #[test]
360 fn an_instant_already_reached_has_passed() {
361 let now = Instant::now();
362 let deadline = Deadline::at(now);
363 assert!(deadline.passed());
364 assert_eq!(deadline.budget(), Some(Duration::ZERO));
365 let later = Deadline::at(now + Duration::from_hours(1));
366 assert!(!later.passed());
367 assert!(later.budget() > Some(Duration::from_secs(3599)));
368 }
369
370 #[test]
371 fn the_check_names_the_operation_and_leaves_the_page_to_the_caller() {
372 let deadline = Deadline::after(Duration::from_secs(5));
373 assert_eq!(deadline.check(Operation::Render), Ok(()));
374 let spent = Deadline::after(Duration::ZERO);
375 let error = spent.check(Operation::Render).expect_err("spent");
376 assert_eq!(
377 error,
378 LimitExceeded::Time {
379 budget: Duration::ZERO,
380 during: Operation::Render,
381 page: None,
382 }
383 );
384 assert_eq!(
385 error.on_page(PageIndex::from(2u32)),
386 LimitExceeded::Time {
387 budget: Duration::ZERO,
388 during: Operation::Render,
389 page: Some(PageIndex::from(2u32)),
390 }
391 );
392 }
393
394 #[test]
395 fn descriptions_take_a_page_only_where_one_makes_sense() {
396 let page = Some(PageIndex::from(3u32));
397 assert_eq!(Operation::Open.describe(page), "opening the document");
398 assert_eq!(Operation::Script.describe(page), "running a script");
399 assert_eq!(Operation::Render.describe(page), "rendering page 3");
400 assert_eq!(Operation::Render.describe(None), "rendering page");
401 assert_eq!(Operation::PageLoad.describe(page), "loading page 3");
402 assert_eq!(Operation::Interpret.describe(page), "interpreting page 3");
403 assert_eq!(
404 Operation::Extract.describe(page),
405 "extracting text from page 3"
406 );
407 }
408}