Skip to main content

denise_forms/
deadline.rs

1//! A clock and something to abandon, for callers that read a form they did not
2//! write.
3//!
4//! [`Form::parse`](crate::Form::parse) is bounded in *shape* — [`MAX_SOURCE`],
5//! [`MAX_DEPTH`], [`MAX_COMMENTED_DEPTH`] and a brace count, all applied by a
6//! byte scan before the file reaches `kdl` — and not in *time*. It cannot be.
7//! `kdl` 6.7.1 parses some malformed documents in exponential time
8//! ([kdl-org/kdl-rs#177](https://github.com/kdl-org/kdl-rs/issues/177)): a
9//! hundred and thirty bytes takes seventy-eight seconds, and a couple of
10//! hundred does not finish. The byte scan refuses every shape the fuzzer has
11//! found, but agreeing with `kdl` about where a string ends means *being*
12//! `kdl`'s lexer, and a fourth divergence turned up five minutes after the
13//! third was fixed. Anything that slips past costs whatever `kdl` costs.
14//!
15//! So the complete answer is a deadline, and it lives here rather than in each
16//! caller because the reason for it does.
17//!
18//! # Abandoned, not stopped
19//!
20//! There is no way to stop a running parse. A thread cannot be cancelled and
21//! `kdl` has no interruption point to ask it at, so what
22//! [`Form::parse_within`] does when the deadline passes is walk away: the call
23//! returns, and the worker keeps parsing until it finishes, which for the
24//! exponential shapes may be never.
25//!
26//! That bounds the *call* and not the *process*, so one abandoned thread would
27//! be a core burned for as long as the program runs, and an unbounded number of
28//! them would be the denial of service this is here to prevent. Hence
29//! [`MAX_ABANDONED`]: a parse whose predecessors are still wedged is refused
30//! before it is started. The alternative — spawning anyway — makes a machine
31//! that opened one hostile file unusable rather than merely annoyed.
32
33use std::panic;
34use std::sync::atomic::{AtomicBool, Ordering};
35use std::sync::mpsc::{self, RecvTimeoutError};
36use std::sync::{Arc, Mutex, PoisonError};
37use std::thread;
38use std::time::Duration;
39
40use crate::error::{At, Error, Reason};
41use crate::form::Form;
42#[allow(unused_imports)] // Named by the module documentation above.
43use crate::form::{MAX_COMMENTED_DEPTH, MAX_DEPTH, MAX_SOURCE};
44
45/// How long to give a form before abandoning it, when there is no better
46/// number to hand.
47///
48/// One second, against release measurements on an M5 Pro: the
49/// [reference form](https://github.com/bisand/denise/blob/main/forms/reference.dform)
50/// — every node kind this toolkit has, in nine and a half kilobytes — parses in
51/// **under 3 ms**, and the other five forms in the repository in under 300 µs
52/// each. So this is three hundred times the slowest real form, and
53/// still short enough that a person reads the pause as *slow* rather than as
54/// *hung*.
55///
56/// It is a default rather than a rule, and [`Form::parse_within`] takes the
57/// number as an argument for two reasons. A file near [`MAX_SOURCE`] is
58/// legitimately slower than this — four megabytes of real nodes measures 1.7 s,
59/// and ten times that unoptimised — so a program that generates enormous forms
60/// has to say so. And the machines this toolkit is for are not the machine
61/// those numbers came off: the margin above is there to be spent, and a panel
62/// that finds it is not enough should ask for more rather than go without.
63pub const PATIENCE: Duration = Duration::from_secs(1);
64
65/// How many parses may be running past their deadline before another is
66/// refused outright.
67///
68/// Each one is a thread that cannot be stopped, so each one is a core this
69/// process will not get back. Four is enough that a person who opens a bad
70/// file, fixes it, and opens it again is never told no, and few enough that a
71/// four-core panel keeps a core to draw with.
72///
73/// Reaching it is [`Reason::NoThread`], and it does not clear: the threads are
74/// wedged for the life of the process, and the honest advice in that message is
75/// to restart.
76pub const MAX_ABANDONED: usize = 4;
77
78/// The "I have finished" flag of every parse that overran its deadline.
79///
80/// Read rather than counted down, because the alternative races: a worker that
81/// finishes in the same instant the caller gives up would otherwise either be
82/// counted forever or not at all. A flag is a fact either side can check
83/// whenever it likes.
84static ABANDONED: Mutex<Vec<Arc<AtomicBool>>> = Mutex::new(Vec::new());
85
86impl Form {
87    /// Parses a form, giving up after `limit`.
88    ///
89    /// Otherwise exactly [`Form::parse`] — same document, same errors, same
90    /// byte-for-byte round trip — with two more ways to fail:
91    /// [`Reason::TooSlow`] when the deadline passes, and [`Reason::NoThread`]
92    /// when the parse could not be started at all.
93    ///
94    /// **Use this for any form the program did not write**: opened by a person,
95    /// pasted, downloaded, handed over on a stick, or watched on disk while a
96    /// text editor has it too. A form compiled in with `include_str!` is read at
97    /// build time from a file in the repository and needs nothing from here.
98    ///
99    /// The call returns within `limit` plus the cost of spawning a thread. What
100    /// it does not do is **stop** the parse. A thread cannot be cancelled and
101    /// `kdl` has no point at which to ask it to stop, so an overrun is
102    /// abandoned: this returns, and the worker keeps parsing until it finishes,
103    /// which for the exponential shapes may be never. That bounds the call and
104    /// not the process, which is what [`MAX_ABANDONED`] is for.
105    ///
106    /// ```
107    /// # use denise_forms::{Form, PATIENCE};
108    /// let source = "form \"F\" version=1 width=64 height=32 {\n\
109    ///     \x20   label \"Hello\" x=0 y=0 w=64 h=16\n\
110    ///     }\n";
111    ///
112    /// let form = Form::parse_within(source, PATIENCE).expect("a form, in time");
113    /// assert_eq!(form.text(), source);
114    /// ```
115    pub fn parse_within(source: &str, limit: Duration) -> Result<Self, Error> {
116        // Two callers can pass this at the same count, so the cap is a bound
117        // and not an invariant: it holds within the number of threads parsing
118        // forms at once, which in every caller here is one. Holding a lock
119        // across a spawn to make it exact would buy a fifth wedged thread's
120        // worth of nothing.
121        let wedged = still_running(&ABANDONED);
122        if wedged >= MAX_ABANDONED {
123            return Err(Error::new(
124                At::START,
125                Reason::NoThread { abandoned: wedged },
126            ));
127        }
128
129        let done = Arc::new(AtomicBool::new(false));
130        let finished = Arc::clone(&done);
131        let owned = source.to_string();
132        // Buffered, so the worker's send never blocks and never depends on
133        // anyone still listening. An abandoned parse must be able to run to its
134        // end and exit rather than parking on a channel forever.
135        let (sender, results) = mpsc::sync_channel(1);
136
137        let Ok(worker) = thread::Builder::new()
138            .name(String::from("dform-parse"))
139            .spawn(move || {
140                let parsed = Self::parse(&owned);
141                let _ = sender.send(parsed);
142                finished.store(true, Ordering::Release);
143            })
144        else {
145            // The system would not give us a thread, so there is nowhere to do
146            // this that can be walked away from. Parsing here anyway is the one
147            // thing this function exists not to do.
148            return Err(Error::new(
149                At::START,
150                Reason::NoThread { abandoned: wedged },
151            ));
152        };
153
154        match results.recv_timeout(limit) {
155            Ok(parsed) => parsed,
156            Err(RecvTimeoutError::Timeout) => {
157                abandon(&ABANDONED, done);
158                Err(Error::new(At::START, Reason::TooSlow { limit }))
159            }
160            // The worker ended without sending, which it can only do by
161            // panicking — the channel has room and the receiver is right here.
162            // A panic in `Form::parse` is a bug, and the fuzz target
163            // `parse_form` exists to find them; putting a deadline on the parse
164            // must not turn one into a quiet `Err`. So it is raised on this
165            // thread, where it would have happened without the deadline. The
166            // join cannot block: a disconnected channel means the closure has
167            // already unwound.
168            Err(RecvTimeoutError::Disconnected) => match worker.join() {
169                Err(panicked) => panic::resume_unwind(panicked),
170                Ok(()) => unreachable!("the worker returned without sending a result"),
171            },
172        }
173    }
174}
175
176/// How many abandoned parses are still running, forgetting the ones that have
177/// since finished.
178///
179/// Split out from [`Form::parse_within`] to be testable: wedging four real
180/// threads to watch the fifth call be refused is not a test, it is a way to
181/// make CI flaky.
182fn still_running(abandoned: &Mutex<Vec<Arc<AtomicBool>>>) -> usize {
183    let mut wedged = abandoned.lock().unwrap_or_else(PoisonError::into_inner);
184    wedged.retain(|done| !done.load(Ordering::Acquire));
185    wedged.len()
186}
187
188/// Takes note of a parse that overran, so that the next caller can count it.
189fn abandon(abandoned: &Mutex<Vec<Arc<AtomicBool>>>, done: Arc<AtomicBool>) {
190    abandoned
191        .lock()
192        .unwrap_or_else(PoisonError::into_inner)
193        .push(done);
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn flag(finished: bool) -> Arc<AtomicBool> {
201        Arc::new(AtomicBool::new(finished))
202    }
203
204    #[test]
205    fn a_form_parses_within_the_default() {
206        let source = std::fs::read_to_string("../forms/reference.dform").expect("the form is here");
207        let form = Form::parse_within(&source, PATIENCE).expect("the reference form, in a second");
208        assert_eq!(form.text(), source);
209    }
210
211    #[test]
212    fn a_deadline_of_nothing_is_never_met() {
213        // The reference form takes milliseconds and a thread takes microseconds
214        // to start, so no scheduling accident makes this finish in no time at
215        // all. Which is the only way to write this test: every input known to
216        // be slow is refused by the byte scan before it reaches `kdl`, so the
217        // way to make a parse miss a deadline is to move the deadline.
218        let source = std::fs::read_to_string("../forms/reference.dform").expect("the form is here");
219        let error = Form::parse_within(&source, Duration::ZERO).expect_err("no time at all");
220        assert_eq!(
221            error.reason,
222            Reason::TooSlow {
223                limit: Duration::ZERO
224            }
225        );
226        assert!(error.to_string().contains("longer than"), "{error}");
227    }
228
229    #[test]
230    fn the_error_a_missed_deadline_gives_is_not_about_the_file() {
231        // A form that is refused for being slow has not been read, so the
232        // position can only be the top of it. Worth asserting: every other
233        // error in this crate points at the byte that caused it, and somebody
234        // will reasonably expect this one to as well.
235        let source = std::fs::read_to_string("../forms/reference.dform").expect("the form is here");
236        let error = Form::parse_within(&source, Duration::ZERO).expect_err("no time at all");
237        assert_eq!(error.at, At::START);
238    }
239
240    #[test]
241    fn a_deadline_changes_nothing_about_what_a_form_means() {
242        // The deadline wraps the parse; it must not stand in front of it. A
243        // file the byte scan refuses has to come back as the refusal it is,
244        // with the position it has, rather than as a timeout — otherwise the
245        // safe call is the one with the worse error messages, and nobody would
246        // use it.
247        let refused = "form \"F\" version=1 width=1 height=1 {\n    panel \"p\" x=0 y=0 w=1 h=1\n";
248        let direct = Form::parse(refused).expect_err("an unclosed brace");
249        let bounded = Form::parse_within(refused, PATIENCE).expect_err("an unclosed brace");
250        assert_eq!(direct.at, bounded.at);
251        assert_eq!(direct.reason, bounded.reason);
252        assert_eq!(bounded.reason, Reason::Unbalanced { open: true });
253    }
254
255    #[test]
256    fn a_parse_that_has_finished_stops_being_counted() {
257        let list = Mutex::new(vec![flag(true), flag(false), flag(true)]);
258        assert_eq!(still_running(&list), 1);
259        // And the finished ones are gone rather than counted again.
260        assert_eq!(list.lock().expect("not poisoned").len(), 1);
261    }
262
263    #[test]
264    fn wedged_parses_pile_up_until_the_limit() {
265        let list = Mutex::new(Vec::new());
266        for _ in 0..MAX_ABANDONED {
267            assert!(still_running(&list) < MAX_ABANDONED);
268            abandon(&list, flag(false));
269        }
270        assert_eq!(still_running(&list), MAX_ABANDONED);
271    }
272
273    #[test]
274    fn no_thread_says_which_of_the_two_things_went_wrong() {
275        let full = Error::new(
276            At::START,
277            Reason::NoThread {
278                abandoned: MAX_ABANDONED,
279            },
280        );
281        assert!(full.to_string().contains("restart"), "{full}");
282
283        let refused = Error::new(At::START, Reason::NoThread { abandoned: 0 });
284        assert!(refused.to_string().contains("thread"), "{refused}");
285        assert!(!refused.to_string().contains("restart"), "{refused}");
286    }
287}