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.
251fn follow_console(sb: Sandbox, prefix: Option<String>, tx: mpsc::Sender<Msg>) {
252    std::thread::spawn(move || {
253        let mut out = LineWriter::new(prefix);
254        // Skip what was logged before this run, like `journalctl -n 0`.
255        let mut pos = sb
256            .client()
257            .console_since(sb.name(), u64::MAX)
258            .map_or(0, |(_, end)| end);
259        loop {
260            std::thread::sleep(Duration::from_secs(1));
261            let Ok((new, end)) = sb.client().console_since(sb.name(), pos) else {
262                continue;
263            };
264            pos = end;
265            if !new.is_empty() && out.write(&mut io::stdout().lock(), &new).is_err() {
266                let _ = tx.send(Msg::OutputClosed);
267                return;
268            }
269        }
270    });
271}
272
273/// Writes output with a prefix at the start of every line.
274struct LineWriter {
275    prefix: Option<String>,
276    at_line_start: bool,
277}
278
279impl LineWriter {
280    fn new(prefix: Option<String>) -> Self {
281        LineWriter {
282            prefix,
283            at_line_start: true,
284        }
285    }
286
287    fn write(&mut self, w: &mut impl Write, buf: &[u8]) -> io::Result<()> {
288        let Some(prefix) = &self.prefix else {
289            w.write_all(buf)?;
290            return w.flush();
291        };
292        for line in buf.split_inclusive(|&b| b == b'\n') {
293            if self.at_line_start {
294                w.write_all(prefix.as_bytes())?;
295            }
296            w.write_all(line)?;
297            self.at_line_start = line.ends_with(b"\n");
298        }
299        w.flush()
300    }
301
302    /// End a trailing partial line.
303    fn finish(&mut self, w: &mut impl Write) -> io::Result<()> {
304        if self.prefix.is_some() && !self.at_line_start {
305            self.at_line_start = true;
306            w.write_all(b"\n")?;
307            return w.flush();
308        }
309        Ok(())
310    }
311}
312
313/// This process's ancestors below pid 1, as (pid, start time) so a reused pid
314/// is not mistaken for the original.
315fn ancestors() -> Vec<(u32, u64)> {
316    let mut out = Vec::new();
317    let Some((mut ppid, _)) = stat(std::process::id()) else {
318        return out;
319    };
320    while ppid > 1 && out.len() < 64 {
321        let Some((next, start)) = stat(ppid) else {
322            break;
323        };
324        out.push((ppid, start));
325        ppid = next;
326    }
327    out
328}
329
330fn alive(pid: u32, start: u64) -> bool {
331    matches!(stat(pid), Some((_, s)) if s == start)
332}
333
334/// (ppid, start time) from `/proc/<pid>/stat`; `None` for a missing or
335/// zombie process.
336#[cfg(target_os = "linux")]
337fn stat(pid: u32) -> Option<(u32, u64)> {
338    let s = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
339    parse_stat(&s)
340}
341
342/// (ppid, start time in microseconds) from `proc_pidinfo`; `None` for a
343/// missing or zombie process.
344#[cfg(target_os = "macos")]
345fn stat(pid: u32) -> Option<(u32, u64)> {
346    let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
347    let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
348    // SAFETY: the buffer is a proc_bsdinfo of exactly `size` bytes.
349    let n = unsafe {
350        libc::proc_pidinfo(
351            libc::pid_t::try_from(pid).ok()?,
352            libc::PROC_PIDTBSDINFO,
353            0,
354            info.as_mut_ptr().cast(),
355            size,
356        )
357    };
358    if n != size {
359        return None;
360    }
361    // SAFETY: proc_pidinfo filled all `size` bytes.
362    let info = unsafe { info.assume_init() };
363    if info.pbi_status == libc::SZOMB {
364        return None;
365    }
366    Some((
367        info.pbi_ppid,
368        info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec,
369    ))
370}
371
372#[cfg(target_os = "linux")]
373fn parse_stat(s: &str) -> Option<(u32, u64)> {
374    // The command name can contain spaces and parentheses: fields resume after
375    // the LAST ')'. Then: state ppid ... with starttime the 20th after it.
376    let rest = &s[s.rfind(')')? + 1..];
377    let f: Vec<&str> = rest.split_whitespace().collect();
378    if matches!(f.first(), Some(&"Z") | Some(&"X")) {
379        return None;
380    }
381    Some((f.get(1)?.parse().ok()?, f.get(19)?.parse().ok()?))
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[cfg(target_os = "linux")]
389    #[test]
390    fn parses_stat() {
391        let line =
392            "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";
393        assert_eq!(parse_stat(line), Some((99, 5555)));
394        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";
395        assert_eq!(parse_stat(zombie), None);
396    }
397
398    #[test]
399    fn own_ancestors_are_alive() {
400        let a = ancestors();
401        assert!(!a.is_empty());
402        assert!(a.iter().all(|&(p, t)| alive(p, t)));
403        assert!(!alive(a[0].0, a[0].1 + 1));
404    }
405
406    #[test]
407    fn prefixes_every_line() {
408        let mut w = LineWriter::new(Some("web | ".into()));
409        let mut out = Vec::new();
410        w.write(&mut out, b"one\ntw").unwrap();
411        w.write(&mut out, b"o\nthree").unwrap();
412        w.finish(&mut out).unwrap();
413        assert_eq!(out, b"web | one\nweb | two\nweb | three\n");
414    }
415}