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