Skip to main content

qframe/runtime/
termination.rs

1//! Ending because the system asked: the causes an application hears, and the one rule that
2//! decides what each further signal of a run does.
3
4use std::time::Duration;
5
6/// Why the system, rather than the user, is ending the application.
7///
8/// On Unix the terminal [`Runtime`](super::Runtime) catches `SIGTERM`, `SIGINT` and `SIGHUP` for
9/// as long as it runs, and a [`Harness`](super::Harness) simulates them with
10/// [`Harness::terminate`](super::Harness::terminate). The application hears the cause through
11/// [`App::terminating`](super::App::terminating), which is how it tells "the system is ending
12/// us" from "the user asked to quit" ([`App::before_quit`](super::App::before_quit)).
13///
14/// Whatever the application answers, the run ends in bounded time:
15///
16/// - Answering `None` quits at once.
17/// - A message keeps the application running while it finishes, e.g. saves and returns
18///   [`Command::quit`](super::Command::quit). After [`Termination::grace`] the runtime quits
19///   without it.
20/// - A second `SIGTERM` or `SIGINT` ends the run at once. When the application does not return
21///   to the loop within a second after that, or after its grace, the runtime restores the
22///   terminal itself and the process ends by the signal, as it would have without the
23///   framework.
24///
25/// Every quit restores the terminal (raw mode off, the normal screen, the cursor) as long as the
26/// terminal still exists; after a hangup nothing is written to it.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28#[non_exhaustive]
29pub enum Termination {
30    /// `SIGTERM`, or `SIGINT` from `kill -INT`: another program or the system (a service
31    /// manager, a shutdown, `kill`) asks the application to end. The terminal is still there, so
32    /// the application may save and quit, and it may even ask the user something, within the
33    /// grace. By default the application answers as for a quit the user asked for, with
34    /// [`App::before_quit`](super::App::before_quit).
35    ///
36    /// Inside the application `ctrl c` is a key, not this signal: raw mode turns the terminal's
37    /// interrupt key off.
38    Terminate,
39    /// `SIGHUP`: the terminal went away, because the SSH connection dropped, the terminal
40    /// window or the `tmux` pane closed. Nobody can answer a question any more and nothing is
41    /// drawn after it, so the application gets one chance to save, without a dialog. By default
42    /// it quits at once.
43    ///
44    /// A hangup that repeats (the shell forwards its own and the system sends another when the
45    /// shell ends) changes nothing. A `SIGHUP` sent by hand while the terminal is still there is
46    /// heard the same way, and the terminal is restored on the way out.
47    Hangup,
48}
49
50impl Termination {
51    /// How long the application has, after this cause, to quit on its own before the runtime
52    /// quits without it: five seconds after [`Termination::Terminate`], three after
53    /// [`Termination::Hangup`]. A hangup during a pending terminate shortens what is left to
54    /// the hangup's grace.
55    ///
56    /// ```
57    /// use std::time::Duration;
58    /// use qframe::runtime::Termination;
59    ///
60    /// assert_eq!(Termination::Terminate.grace(), Duration::from_secs(5));
61    /// assert_eq!(Termination::Hangup.grace(), Duration::from_secs(3));
62    /// ```
63    #[must_use]
64    pub const fn grace(self) -> Duration {
65        match self {
66            Self::Terminate => Duration::from_secs(5),
67            Self::Hangup => Duration::from_secs(3),
68        }
69    }
70}
71
72/// A termination the application was told about and has not finished yet.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub(crate) struct Ending {
75    /// The cause the application heard last.
76    pub(crate) cause: Termination,
77    /// When the run quits even if the application has not.
78    pub(crate) deadline: Duration,
79}
80
81/// What a run does with one more signal.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub(crate) enum Step {
84    /// Tell the application, through `App::terminating`, with this cause.
85    Ask(Termination),
86    /// Nothing new: a hangup repeated.
87    Ignore,
88    /// End the run now.
89    End,
90}
91
92/// Moves `ending` along for a signal of `cause` arriving at `now`. The engine and the signal
93/// watcher both follow this rule, each with its own clock, so they agree on every step.
94///
95/// The first signal asks. A hangup after a terminate asks again, since the answer to the
96/// terminate may be a question nobody can see now, and leaves the shorter grace. A hangup after
97/// a hangup is the same hangup heard twice. A terminate after anything is the user or the system
98/// insisting, and ends the run.
99pub(crate) fn receive(ending: &mut Option<Ending>, cause: Termination, now: Duration) -> Step {
100    match (*ending, cause) {
101        (None, _) => {
102            *ending = Some(Ending { cause, deadline: now + cause.grace() });
103            Step::Ask(cause)
104        }
105        (Some(Ending { cause: Termination::Hangup, .. }), Termination::Hangup) => Step::Ignore,
106        (Some(current), Termination::Hangup) => {
107            let deadline = current.deadline.min(now + Termination::Hangup.grace());
108            *ending = Some(Ending { cause, deadline });
109            Step::Ask(cause)
110        }
111        (Some(_), Termination::Terminate) => Step::End,
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    const SECOND: Duration = Duration::from_secs(1);
120
121    #[test]
122    fn the_first_signal_asks_and_starts_its_grace() {
123        let mut ending = None;
124        assert_eq!(receive(&mut ending, Termination::Terminate, SECOND), Step::Ask(Termination::Terminate));
125        assert_eq!(ending, Some(Ending { cause: Termination::Terminate, deadline: SECOND * 6 }));
126        let mut ending = None;
127        assert_eq!(receive(&mut ending, Termination::Hangup, SECOND), Step::Ask(Termination::Hangup));
128        assert_eq!(ending, Some(Ending { cause: Termination::Hangup, deadline: SECOND * 4 }));
129    }
130
131    #[test]
132    fn a_second_terminate_ends_whatever_came_first() {
133        for first in [Termination::Terminate, Termination::Hangup] {
134            let mut ending = None;
135            receive(&mut ending, first, Duration::ZERO);
136            assert_eq!(receive(&mut ending, Termination::Terminate, SECOND), Step::End, "after {first:?}");
137        }
138    }
139
140    #[test]
141    fn a_repeated_hangup_changes_nothing() {
142        let mut ending = None;
143        receive(&mut ending, Termination::Hangup, Duration::ZERO);
144        let before = ending;
145        assert_eq!(receive(&mut ending, Termination::Hangup, SECOND), Step::Ignore);
146        assert_eq!(ending, before, "the deadline stays where the first hangup put it");
147    }
148
149    #[test]
150    fn a_hangup_during_a_terminate_asks_again_with_the_shorter_grace() {
151        let mut ending = None;
152        receive(&mut ending, Termination::Terminate, Duration::ZERO);
153        assert_eq!(receive(&mut ending, Termination::Hangup, SECOND), Step::Ask(Termination::Hangup));
154        assert_eq!(ending, Some(Ending { cause: Termination::Hangup, deadline: SECOND * 4 }));
155        // Late in the terminate's grace, what is left of it is shorter than a hangup's.
156        let mut ending = None;
157        receive(&mut ending, Termination::Terminate, Duration::ZERO);
158        receive(&mut ending, Termination::Hangup, SECOND * 4);
159        assert_eq!(ending.map(|ending| ending.deadline), Some(SECOND * 5));
160    }
161}