Skip to main content

helixforge_engine/
pty.rs

1//! A shell program running in a pseudo-terminal, with its output collected
2//! for an interface to draw and its input forwarded from the keyboard.
3//!
4//! The shell leads its own session and process group, and everything it starts
5//! stays in that group unless it leaves on purpose. Stopping a run signals the
6//! whole group, so a child that outlives the shell is still reached.
7
8use crate::{EngineError, LIB_VARIABLE};
9use nix::{
10    sys::signal::{Signal, killpg},
11    unistd::Pid,
12};
13use portable_pty::{CommandBuilder, ExitStatus, MasterPty, NativePtySystem, PtySize, PtySystem};
14use std::{
15    io::{Read, Write},
16    path::Path,
17    sync::{
18        Arc, Mutex,
19        atomic::{AtomicBool, Ordering},
20        mpsc::{Receiver, channel},
21    },
22    thread::JoinHandle,
23};
24
25/// Set whenever new output arrived, for interfaces that redraw on demand.
26pub static OUTPUT_CHANGED: AtomicBool = AtomicBool::new(false);
27
28pub struct Session {
29    output: Arc<Mutex<Vec<u8>>>,
30    waiter: Option<JoinHandle<Option<ExitStatus>>>,
31    /// Delivers the shell's process id, which is also its process group.
32    started: Option<Receiver<u32>>,
33    group: Option<Pid>,
34    master: Box<dyn MasterPty + Send>,
35    writer: Box<dyn Write + Send>,
36    status: Option<Option<ExitStatus>>,
37    killed: bool,
38}
39
40impl Session {
41    pub fn spawn(program: &str, lib_dir: &Path) -> Result<Self, EngineError> {
42        let mut builder = CommandBuilder::new("sh");
43        builder.args(["-c", program]);
44        builder.env("TERM", "xterm-256color");
45        builder.env("COLORTERM", "truecolor");
46        builder.env(LIB_VARIABLE, lib_dir);
47        let pair = NativePtySystem::default()
48            .openpty(PtySize {
49                rows: 24,
50                cols: 80,
51                pixel_width: 0,
52                pixel_height: 0,
53            })
54            .map_err(|error| EngineError::Spawn(std::io::Error::other(error)))?;
55        let (sender, receiver) = channel();
56        let waiter = std::thread::spawn(move || {
57            let mut child = pair.slave.spawn_command(builder).ok()?;
58            if let Some(pid) = child.process_id() {
59                let _ = sender.send(pid);
60            }
61            child.wait().ok()
62        });
63        let mut reader = pair
64            .master
65            .try_clone_reader()
66            .map_err(|error| EngineError::Spawn(std::io::Error::other(error)))?;
67        let output: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
68        let shared = output.clone();
69        std::thread::spawn(move || {
70            let mut buffer = [0u8; 8192];
71            while let Ok(size) = reader.read(&mut buffer) {
72                if size == 0 {
73                    break;
74                }
75                shared.lock().unwrap().extend_from_slice(&buffer[..size]);
76                OUTPUT_CHANGED.store(true, Ordering::Release);
77            }
78            OUTPUT_CHANGED.store(true, Ordering::Release);
79        });
80        let writer = pair
81            .master
82            .take_writer()
83            .map_err(|error| EngineError::Spawn(std::io::Error::other(error)))?;
84        OUTPUT_CHANGED.store(true, Ordering::Release);
85        Ok(Self {
86            output,
87            waiter: Some(waiter),
88            started: Some(receiver),
89            group: None,
90            master: pair.master,
91            writer,
92            status: None,
93            killed: false,
94        })
95    }
96
97    /// How the run ended, once it has.
98    pub fn outcome(&mut self) -> Option<crate::Outcome> {
99        if !self.is_finished() {
100            return None;
101        }
102        Some(match self.exit_status() {
103            _ if self.killed => crate::Outcome::Interrupted,
104            Some(status) if status.success() => crate::Outcome::Success,
105            Some(status) => {
106                let code = status.exit_code();
107                if code >= 128 {
108                    crate::Outcome::Interrupted
109                } else {
110                    crate::Outcome::Failed { exit_code: code }
111                }
112            }
113            None => crate::Outcome::CouldNotStart("the shell did not start".into()),
114        })
115    }
116
117    /// Everything the program has written so far.
118    pub fn output(&self) -> Vec<u8> {
119        self.output.lock().unwrap().clone()
120    }
121
122    pub fn is_finished(&self) -> bool {
123        self.waiter.as_ref().is_none_or(JoinHandle::is_finished)
124    }
125
126    /// The exit status once the program has finished; blocks until then.
127    pub fn exit_status(&mut self) -> Option<ExitStatus> {
128        if self.status.is_none()
129            && let Some(waiter) = self.waiter.take()
130        {
131            self.status = Some(waiter.join().ok().flatten());
132        }
133        self.status.clone().flatten()
134    }
135
136    /// The shell's process group, once it has started.
137    fn group(&mut self) -> Option<Pid> {
138        if self.group.is_none()
139            && let Some(receiver) = self.started.take()
140        {
141            // The sender is dropped without sending only when the spawn failed.
142            if let Ok(pid) = receiver.recv() {
143                self.group = i32::try_from(pid).ok().map(Pid::from_raw);
144            }
145        }
146        self.group
147    }
148
149    /// Stop the run. The first request sends the hangup a closing terminal
150    /// would send to every process in the run's group; a later one, while any
151    /// of them is still running, kills them outright.
152    pub fn kill(&mut self) {
153        let Some(group) = self.group() else {
154            return;
155        };
156        if !self.killed {
157            self.killed = true;
158            match killpg(group, Signal::SIGHUP) {
159                Ok(()) => tracing::info!("run stopped by request"),
160                Err(error) => tracing::warn!(%error, "the run could not be stopped"),
161            }
162        } else if self.survivors() > 0 {
163            // The group still has members, so its id cannot have been reused.
164            match killpg(group, Signal::SIGKILL) {
165                Ok(()) => tracing::info!("remaining processes killed by request"),
166                Err(error) => tracing::warn!(%error, "the remaining processes could not be killed"),
167            }
168        }
169    }
170
171    /// How many processes of the run are still alive, for example ones that
172    /// ignored the hangup after the shell itself ended.
173    pub fn survivors(&mut self) -> usize {
174        let Some(group) = self.group() else {
175            return 0;
176        };
177        let Ok(listing) = std::fs::read_dir("/proc") else {
178            return 0;
179        };
180        listing
181            .flatten()
182            .filter(|item| {
183                item.file_name()
184                    .to_str()
185                    .is_some_and(|name| name.bytes().all(|b| b.is_ascii_digit()))
186            })
187            .filter_map(|item| std::fs::read_to_string(item.path().join("stat")).ok())
188            .filter(|stat| {
189                // After the command name: state, parent, process group, ...
190                let mut fields = stat
191                    .rsplit_once(')')
192                    .map(|(_, rest)| rest)
193                    .unwrap_or("")
194                    .split_whitespace();
195                let state = fields.next().unwrap_or("Z");
196                let process_group = fields.nth(1).and_then(|field| field.parse::<i32>().ok());
197                state != "Z" && state != "X" && process_group == Some(group.as_raw())
198            })
199            .count()
200    }
201
202    /// True once a stop was requested.
203    pub fn stop_requested(&self) -> bool {
204        self.killed
205    }
206
207    pub fn resize(&self, rows: u16, cols: u16) {
208        let _ = self.master.resize(PtySize {
209            rows: rows.max(1),
210            cols: cols.max(1),
211            pixel_width: 0,
212            pixel_height: 0,
213        });
214    }
215
216    /// Send keyboard input to the program.
217    pub fn write(&mut self, bytes: &[u8]) {
218        let _ = self.writer.write_all(bytes);
219        let _ = self.writer.flush();
220    }
221}
222
223impl Drop for Session {
224    fn drop(&mut self) {
225        if !self.is_finished() {
226            self.kill();
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use std::time::{Duration, Instant};
235
236    #[test]
237    fn output_and_exit_status_come_back() {
238        let mut session = Session::spawn(
239            "printf 'hello %s\\n' \"$HELIXFORGE_LIB\"; exit 3",
240            Path::new("/lib/dir"),
241        )
242        .unwrap();
243        let start = Instant::now();
244        while !session.is_finished() && start.elapsed() < Duration::from_secs(10) {
245            std::thread::sleep(Duration::from_millis(20));
246        }
247        assert_eq!(
248            session.exit_status().map(|status| status.exit_code()),
249            Some(3)
250        );
251        let start = Instant::now();
252        while !String::from_utf8_lossy(&session.output()).contains("hello /lib/dir")
253            && start.elapsed() < Duration::from_secs(5)
254        {
255            std::thread::sleep(Duration::from_millis(20));
256        }
257        assert!(String::from_utf8_lossy(&session.output()).contains("hello /lib/dir"));
258    }
259
260    #[test]
261    fn input_reaches_the_program_and_kill_stops_it() {
262        let mut session =
263            Session::spawn("read -r line; echo \"got $line\"; sleep 30", Path::new("/")).unwrap();
264        session.write(b"abc\r");
265        let start = Instant::now();
266        while !String::from_utf8_lossy(&session.output()).contains("got abc")
267            && start.elapsed() < Duration::from_secs(5)
268        {
269            std::thread::sleep(Duration::from_millis(20));
270        }
271        assert!(String::from_utf8_lossy(&session.output()).contains("got abc"));
272        assert!(!session.is_finished());
273        session.kill();
274        let start = Instant::now();
275        while !session.is_finished() && start.elapsed() < Duration::from_secs(5) {
276            std::thread::sleep(Duration::from_millis(20));
277        }
278        assert!(session.is_finished());
279        assert_eq!(session.outcome(), Some(crate::Outcome::Interrupted));
280    }
281
282    #[test]
283    fn a_child_that_ignores_the_hangup_is_reported_and_killed_on_the_second_stop() {
284        let directory = tempfile::tempdir().unwrap();
285        let pid_file = directory.path().join("pid");
286        let program = format!(
287            "(trap '' HUP; sh -c 'echo $$ > {}; exec sleep 300')",
288            pid_file.display()
289        );
290        let mut session = Session::spawn(&program, Path::new("/")).unwrap();
291        let start = Instant::now();
292        while std::fs::read_to_string(&pid_file).map_or(true, |text| text.trim().is_empty())
293            && start.elapsed() < Duration::from_secs(5)
294        {
295            std::thread::sleep(Duration::from_millis(20));
296        }
297        let child = std::fs::read_to_string(&pid_file)
298            .unwrap()
299            .trim()
300            .to_string();
301        let alive = || {
302            std::fs::read_to_string(format!("/proc/{child}/stat")).is_ok_and(|stat| {
303                !stat
304                    .rsplit_once(')')
305                    .unwrap()
306                    .1
307                    .trim_start()
308                    .starts_with('Z')
309            })
310        };
311        session.kill();
312        let start = Instant::now();
313        while !session.is_finished() && start.elapsed() < Duration::from_secs(5) {
314            std::thread::sleep(Duration::from_millis(20));
315        }
316        assert_eq!(session.outcome(), Some(crate::Outcome::Interrupted));
317        std::thread::sleep(Duration::from_millis(200));
318        assert!(alive(), "the child should have ignored the hangup");
319        assert_eq!(session.survivors(), 1);
320        session.kill();
321        let start = Instant::now();
322        while alive() && start.elapsed() < Duration::from_secs(5) {
323            std::thread::sleep(Duration::from_millis(20));
324        }
325        assert!(!alive(), "the second stop must kill the child");
326        assert_eq!(session.survivors(), 0);
327    }
328}