1use 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
25pub static OUTPUT_CHANGED: AtomicBool = AtomicBool::new(false);
27
28pub struct Session {
29 output: Arc<Mutex<Vec<u8>>>,
30 reading: Arc<AtomicBool>,
32 waiter: Option<JoinHandle<Option<ExitStatus>>>,
33 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 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 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 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 pub fn output(&self) -> Vec<u8> {
144 self.output.lock().unwrap().clone()
145 }
146
147 pub(crate) fn transcript(&self) -> Arc<Mutex<Vec<u8>>> {
149 self.output.clone()
150 }
151
152 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 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 fn group(&mut self) -> Option<Pid> {
174 if self.group.is_none()
175 && let Some(receiver) = self.started.take()
176 {
177 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 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 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 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 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 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 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}