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}