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, 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 waiter: Option<JoinHandle<Option<ExitStatus>>>,
31 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 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 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 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 fn group(&mut self) -> Option<Pid> {
138 if self.group.is_none()
139 && let Some(receiver) = self.started.take()
140 {
141 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 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 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 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 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 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 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}