Skip to main content

isb_core/
foreground.rs

1//! Foreground `isb up`: run each sandbox's `command`, stream its output, and
2//! stop the sandboxes when it is over.
3//!
4//! "Over" is any of: every command has exited, SIGINT/SIGTERM/SIGHUP, stdout
5//! went away (the reader of a pipe exited), or a process that started isb
6//! exited. The last one is what signals cannot give: an agent's background task
7//! or a closed terminal can end without ever signalling its descendants, which
8//! otherwise leaves the sandbox (and its published ports) running with nobody
9//! attached. isb records its ancestors at startup and polls them.
10
11use std::io::{self, Write};
12use std::sync::mpsc;
13use std::time::Duration;
14
15use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGTERM};
16
17use crate::error::Result;
18use crate::exec::{ExecEvent, ExecOptions};
19use crate::sandbox::Sandbox;
20
21/// One sandbox to hold in the foreground.
22pub struct Service {
23    /// Service name, used as the log prefix.
24    pub name: String,
25    pub sandbox: Sandbox,
26    pub run: Run,
27}
28
29/// What a held service runs or shows.
30#[derive(Debug, Clone, PartialEq)]
31pub enum Run {
32    /// Nothing: held until something else ends the run.
33    Hold,
34    /// Its `command`, run with the sandbox's exec defaults. The run is over
35    /// when every command has exited.
36    Command(Vec<String>),
37    /// Follow a log with this argv, as root: a supervised app's journal. It
38    /// never ends the run by exiting.
39    Follow(Vec<String>),
40    /// Follow the console log: an OCI app's output.
41    Console,
42}
43
44#[derive(Debug, Clone, Copy)]
45pub struct Options {
46    /// Prefix each output line with `<service> | `.
47    pub log_prefix: bool,
48    /// How long a clean shutdown may take before the sandbox is killed.
49    pub stop_timeout: Duration,
50    /// How often to check that the processes that started isb are still alive.
51    pub poll: Duration,
52}
53
54impl Default for Options {
55    fn default() -> Self {
56        Options {
57            log_prefix: true,
58            stop_timeout: Duration::from_secs(10),
59            poll: Duration::from_secs(1),
60        }
61    }
62}
63
64enum Msg {
65    Signal(i32),
66    Orphaned(u32),
67    OutputClosed,
68    Exited(usize, Result<i32>),
69    Stopped,
70}
71
72/// Run the commands and hold the sandboxes until the run is over, then stop
73/// them. Returns the exit code: the first failing command's status (0 if all
74/// succeeded), 128+N for signal N, 129 when a parent process went away, and
75/// 141 when stdout closed.
76#[expect(
77    clippy::too_many_lines,
78    reason = "predates the lint ratchet; split it when next changed"
79)]
80pub fn run(services: &[Service], opts: Options, report: &mut dyn FnMut(&str)) -> Result<u8> {
81    let (tx, rx) = mpsc::channel::<Msg>();
82
83    let mut signals = signal_hook::iterator::Signals::new([SIGINT, SIGTERM, SIGHUP])?;
84    let sig_handle = signals.handle();
85    {
86        let tx = tx.clone();
87        std::thread::spawn(move || {
88            for sig in signals.forever() {
89                if tx.send(Msg::Signal(sig)).is_err() {
90                    break;
91                }
92            }
93        });
94    }
95
96    let ancestors = ancestors();
97    {
98        let tx = tx.clone();
99        std::thread::spawn(move || {
100            loop {
101                std::thread::sleep(opts.poll);
102                if let Some(&(pid, _)) = ancestors.iter().find(|&&(p, t)| !alive(p, t)) {
103                    let _ = tx.send(Msg::Orphaned(pid));
104                    break;
105                }
106            }
107        });
108    }
109
110    let mut running = 0usize;
111    let mut followed = 0usize;
112    for (i, svc) in services.iter().enumerate() {
113        let prefix = opts.log_prefix.then(|| format!("{} | ", svc.name));
114        let (argv, eopts) = match &svc.run {
115            Run::Hold => continue,
116            Run::Console => {
117                followed += 1;
118                follow_console(svc.sandbox.clone(), prefix, tx.clone());
119                continue;
120            }
121            Run::Follow(argv) => (argv, ExecOptions::default().user("root").cwd("/")),
122            Run::Command(argv) => (argv, ExecOptions::default()),
123        };
124        let counts = matches!(svc.run, Run::Command(_));
125        let stream = match svc.sandbox.exec_stream(argv.clone(), eopts) {
126            Ok(s) => s,
127            Err(e) if counts => {
128                report(&format!("{}: command failed to start: {e}", svc.name));
129                let _ = tx.send(Msg::Exited(i, Err(e)));
130                running += 1;
131                continue;
132            }
133            Err(e) => {
134                report(&format!("{}: cannot follow its log: {e}", svc.name));
135                continue;
136            }
137        };
138        if counts {
139            running += 1;
140        } else {
141            followed += 1;
142        }
143        let tx = tx.clone();
144        std::thread::spawn(move || {
145            let mut out = LineWriter::new(prefix.clone());
146            let mut err = LineWriter::new(prefix);
147            let mut stream = stream;
148            let mut closed = false;
149            while let Some(ev) = stream.next_event() {
150                let r = match ev {
151                    ExecEvent::Stdout(b) => out.write(&mut io::stdout().lock(), &b),
152                    ExecEvent::Stderr(b) => err.write(&mut io::stderr().lock(), &b),
153                };
154                if r.is_err() && !closed {
155                    closed = true;
156                    let _ = tx.send(Msg::OutputClosed);
157                }
158            }
159            let _ = out.finish(&mut io::stdout().lock());
160            let _ = err.finish(&mut io::stderr().lock());
161            let r = stream.wait();
162            if counts {
163                let _ = tx.send(Msg::Exited(i, r));
164            }
165        });
166    }
167
168    if running == 0 {
169        let names: Vec<&str> = services.iter().map(|s| s.name.as_str()).collect();
170        let why = if followed > 0 {
171            "supervised, so only Ctrl-C ends this"
172        } else {
173            "no command, so nothing else will"
174        };
175        report(&format!("{}: up; Ctrl-C stops ({why})", names.join(", ")));
176    }
177
178    let mut first_failure: Option<i32> = None;
179    let code: u8 = loop {
180        match rx.recv() {
181            Ok(Msg::Exited(i, r)) => {
182                let name = &services[i].name;
183                match r {
184                    Ok(c) => {
185                        report(&format!("{name}: command exited with code {c}"));
186                        if c != 0 && first_failure.is_none() {
187                            first_failure = Some(c);
188                        }
189                    }
190                    Err(e) => {
191                        report(&format!("{name}: command failed: {e}"));
192                        first_failure.get_or_insert(1);
193                    }
194                }
195                running -= 1;
196                if running == 0 {
197                    break first_failure.map_or(0, |c| c.clamp(1, 255) as u8);
198                }
199            }
200            Ok(Msg::Signal(s)) => break 128 + s as u8,
201            Ok(Msg::Orphaned(pid)) => {
202                report(&format!("process {pid}, which started isb, has exited"));
203                break 129;
204            }
205            Ok(Msg::OutputClosed) => break 141,
206            Ok(Msg::Stopped) => unreachable!("stop has not started"),
207            Err(_) => break 1,
208        }
209    };
210
211    // A clean stop, in the background, so a second Ctrl-C can force it.
212    let handles: Vec<Sandbox> = services.iter().map(|s| s.sandbox.clone()).collect();
213    let names: Vec<String> = services.iter().map(|s| s.name.clone()).collect();
214    for n in &names {
215        report(&format!("{n}: stopping"));
216    }
217    {
218        let tx = tx.clone();
219        let handles = handles.clone();
220        std::thread::spawn(move || {
221            for sb in &handles {
222                if sb.stop(false, opts.stop_timeout).is_err() {
223                    let _ = sb.stop(true, opts.stop_timeout);
224                }
225            }
226            let _ = tx.send(Msg::Stopped);
227        });
228    }
229    loop {
230        match rx.recv() {
231            Ok(Msg::Stopped) | Err(_) => break,
232            Ok(Msg::Signal(_)) => {
233                report("forcing stop");
234                for sb in &handles {
235                    let _ = sb.stop(true, opts.stop_timeout);
236                }
237                break;
238            }
239            Ok(_) => {}
240        }
241    }
242    for n in &names {
243        report(&format!("{n}: stopped"));
244    }
245    sig_handle.close();
246    Ok(code)
247}
248
249/// Poll an instance's console log and print what is new. incus offers no
250/// streaming read of it; a log that shrank (the instance restarted) is printed
251/// from its start.
252fn follow_console(sb: Sandbox, prefix: Option<String>, tx: mpsc::Sender<Msg>) {
253    std::thread::spawn(move || {
254        let mut out = LineWriter::new(prefix);
255        // Skip what was logged before this run, like `journalctl -n 0`.
256        let mut seen = sb
257            .client()
258            .console_log(sb.name())
259            .map(|b| b.len())
260            .unwrap_or(0);
261        loop {
262            std::thread::sleep(Duration::from_secs(1));
263            let Ok(log) = sb.client().console_log(sb.name()) else {
264                continue;
265            };
266            if log.len() < seen {
267                seen = 0;
268            }
269            if log.len() > seen {
270                if out.write(&mut io::stdout().lock(), &log[seen..]).is_err() {
271                    let _ = tx.send(Msg::OutputClosed);
272                    return;
273                }
274                seen = log.len();
275            }
276        }
277    });
278}
279
280/// Writes output with a prefix at the start of every line.
281struct LineWriter {
282    prefix: Option<String>,
283    at_line_start: bool,
284}
285
286impl LineWriter {
287    fn new(prefix: Option<String>) -> Self {
288        LineWriter {
289            prefix,
290            at_line_start: true,
291        }
292    }
293
294    fn write(&mut self, w: &mut impl Write, buf: &[u8]) -> io::Result<()> {
295        let Some(prefix) = &self.prefix else {
296            w.write_all(buf)?;
297            return w.flush();
298        };
299        for line in buf.split_inclusive(|&b| b == b'\n') {
300            if self.at_line_start {
301                w.write_all(prefix.as_bytes())?;
302            }
303            w.write_all(line)?;
304            self.at_line_start = line.ends_with(b"\n");
305        }
306        w.flush()
307    }
308
309    /// End a trailing partial line.
310    fn finish(&mut self, w: &mut impl Write) -> io::Result<()> {
311        if self.prefix.is_some() && !self.at_line_start {
312            self.at_line_start = true;
313            w.write_all(b"\n")?;
314            return w.flush();
315        }
316        Ok(())
317    }
318}
319
320/// This process's ancestors below pid 1, as (pid, start time) so a reused pid
321/// is not mistaken for the original.
322fn ancestors() -> Vec<(u32, u64)> {
323    let mut out = Vec::new();
324    let Some((mut ppid, _)) = stat(std::process::id()) else {
325        return out;
326    };
327    while ppid > 1 && out.len() < 64 {
328        let Some((next, start)) = stat(ppid) else {
329            break;
330        };
331        out.push((ppid, start));
332        ppid = next;
333    }
334    out
335}
336
337fn alive(pid: u32, start: u64) -> bool {
338    matches!(stat(pid), Some((_, s)) if s == start)
339}
340
341/// (ppid, start time) from `/proc/<pid>/stat`; `None` for a missing or
342/// zombie process.
343#[cfg(target_os = "linux")]
344fn stat(pid: u32) -> Option<(u32, u64)> {
345    let s = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
346    parse_stat(&s)
347}
348
349/// (ppid, start time in microseconds) from `proc_pidinfo`; `None` for a
350/// missing or zombie process.
351#[cfg(target_os = "macos")]
352fn stat(pid: u32) -> Option<(u32, u64)> {
353    let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
354    let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
355    // SAFETY: the buffer is a proc_bsdinfo of exactly `size` bytes.
356    let n = unsafe {
357        libc::proc_pidinfo(
358            libc::pid_t::try_from(pid).ok()?,
359            libc::PROC_PIDTBSDINFO,
360            0,
361            info.as_mut_ptr().cast(),
362            size,
363        )
364    };
365    if n != size {
366        return None;
367    }
368    // SAFETY: proc_pidinfo filled all `size` bytes.
369    let info = unsafe { info.assume_init() };
370    if info.pbi_status == libc::SZOMB {
371        return None;
372    }
373    Some((
374        info.pbi_ppid,
375        info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec,
376    ))
377}
378
379#[cfg(target_os = "linux")]
380fn parse_stat(s: &str) -> Option<(u32, u64)> {
381    // The command name can contain spaces and parentheses: fields resume after
382    // the LAST ')'. Then: state ppid ... with starttime the 20th after it.
383    let rest = &s[s.rfind(')')? + 1..];
384    let f: Vec<&str> = rest.split_whitespace().collect();
385    if matches!(f.first(), Some(&"Z") | Some(&"X")) {
386        return None;
387    }
388    Some((f.get(1)?.parse().ok()?, f.get(19)?.parse().ok()?))
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[cfg(target_os = "linux")]
396    #[test]
397    fn parses_stat() {
398        let line =
399            "1234 (my (odd) cmd) S 99 1234 1234 0 -1 4194304 1 0 0 0 0 0 0 0 20 0 1 0 5555 0 0";
400        assert_eq!(parse_stat(line), Some((99, 5555)));
401        let zombie = "1234 (x) Z 99 1234 1234 0 -1 4194304 1 0 0 0 0 0 0 0 20 0 1 0 5555 0 0";
402        assert_eq!(parse_stat(zombie), None);
403    }
404
405    #[test]
406    fn own_ancestors_are_alive() {
407        let a = ancestors();
408        assert!(!a.is_empty());
409        assert!(a.iter().all(|&(p, t)| alive(p, t)));
410        assert!(!alive(a[0].0, a[0].1 + 1));
411    }
412
413    #[test]
414    fn prefixes_every_line() {
415        let mut w = LineWriter::new(Some("web | ".into()));
416        let mut out = Vec::new();
417        w.write(&mut out, b"one\ntw").unwrap();
418        w.write(&mut out, b"o\nthree").unwrap();
419        w.finish(&mut out).unwrap();
420        assert_eq!(out, b"web | one\nweb | two\nweb | three\n");
421    }
422}