Skip to main content

qk_executor/
process.rs

1use std::collections::BTreeSet;
2use std::process::{Command, Stdio};
3use std::sync::Mutex;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::time::{Duration, Instant};
6
7use anyhow::{Context, Result};
8use command_group::{CommandGroup, GroupChild};
9
10use crate::{Capture, Decoration, Display, PreparedTask, Shown};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Outcome {
14    Success,
15    Failed(i32),
16    Cancelled,
17}
18
19/// The running commands, whose ids the task publishes while they run.
20struct Children<'a>(Vec<GroupChild>, &'a Mutex<BTreeSet<u32>>);
21
22impl Children<'_> {
23    fn push(&mut self, child: GroupChild) {
24        self.1.lock().unwrap().insert(child.id());
25        self.0.push(child);
26    }
27
28    fn remove(&mut self, index: usize) -> GroupChild {
29        let child = self.0.swap_remove(index);
30        self.1.lock().unwrap().remove(&child.id());
31        child
32    }
33}
34
35impl Drop for Children<'_> {
36    fn drop(&mut self) {
37        // Also clean up when spawning or polling one command fails.
38        for child in &mut self.0 {
39            let _ = child.kill();
40        }
41        for child in &mut self.0 {
42            let _ = child.wait();
43        }
44        let mut ids = self.1.lock().unwrap();
45        for child in &self.0 {
46            ids.remove(&child.id());
47        }
48    }
49}
50
51/// How long a cancelled or stopped command may take to exit after SIGTERM.
52#[cfg(unix)]
53const GRACE: Duration = Duration::from_secs(5);
54
55impl Children<'_> {
56    /// Asks every process group to exit, then leaves stragglers to `Drop`.
57    fn terminate(&mut self) {
58        #[cfg(unix)]
59        {
60            use command_group::{Signal, UnixChildExt};
61            for child in &self.0 {
62                let _ = child.signal(Signal::SIGTERM);
63            }
64            let deadline = std::time::Instant::now() + GRACE;
65            while std::time::Instant::now() < deadline
66                && self
67                    .0
68                    .iter_mut()
69                    .any(|child| matches!(child.try_wait(), Ok(None)))
70            {
71                std::thread::sleep(Duration::from_millis(20));
72            }
73        }
74    }
75}
76
77pub fn execute(task: &PreparedTask, cancelled: &AtomicBool) -> Result<Outcome> {
78    if task.display == Display::Stream
79        && task.ready_when.is_empty()
80        && task.decorations.iter().all(Decoration::is_empty)
81    {
82        // Straight through: the task writes to the terminal itself.
83        return execute_captured(task, cancelled, None);
84    }
85    let capture =
86        Capture::new(None, task.display.clone()).ready_when(&task.ready_when, task.ready.clone());
87    execute_captured(task, cancelled, Some(&capture))
88}
89
90pub fn execute_captured(
91    task: &PreparedTask,
92    cancelled: &AtomicBool,
93    capture: Option<&Capture>,
94) -> Result<Outcome> {
95    // Takes the terminal back from an interactive command, however it ends.
96    let _terminal = (task.interactive && task.commands.len() == 1)
97        .then(terminal::Foreground::take_back_on_drop);
98    std::thread::scope(|scope| {
99        let mut readers = Vec::new();
100        let outcome = (|| {
101            let mut pending = task.commands.iter().enumerate();
102            let mut children = Children(Vec::new(), &task.processes);
103            let mut launched = Instant::now();
104            loop {
105                if cancelled.load(Ordering::SeqCst) {
106                    children.terminate();
107                    return Ok(Outcome::Cancelled);
108                }
109                if children.0.is_empty() || task.parallel {
110                    for (index, command) in pending.by_ref() {
111                        let interactive = task.interactive && task.commands.len() == 1;
112                        let mut child = spawn(task, command, index, capture.is_some(), interactive)
113                            .with_context(|| format!("cannot start command for {}", task.id))?;
114                        if let Some(capture) = capture {
115                            let stdout =
116                                child.inner().stdout.take().context("missing stdout pipe")?;
117                            let stderr =
118                                child.inner().stderr.take().context("missing stderr pipe")?;
119                            let decoration = task.decorations.get(index);
120                            readers
121                                .push(scope.spawn(move || capture.copy(stdout, false, decoration)));
122                            readers
123                                .push(scope.spawn(move || capture.copy(stderr, true, decoration)));
124                        }
125                        children.push(child);
126                        launched = Instant::now();
127                        if !task.parallel {
128                            break;
129                        }
130                    }
131                }
132                if children.0.is_empty() {
133                    return Ok(Outcome::Success);
134                }
135                let mut index = 0;
136                while index < children.0.len() {
137                    match children.0[index]
138                        .try_wait()
139                        .context("cannot wait for command")?
140                    {
141                        Some(status) => {
142                            let mut child = children.remove(index);
143                            // Finite tasks may not leave background descendants running.
144                            let _ = child.kill();
145                            if !status.success() {
146                                // As in Nx, commands run side by side fail the
147                                // task with 1, one after another with the code.
148                                let code = if task.parallel
149                                    && task.commands.len() > 1
150                                    && task.ready_when.is_empty()
151                                {
152                                    1
153                                } else {
154                                    exit_code(status)
155                                };
156                                return Ok(Outcome::Failed(code));
157                            }
158                        }
159                        None => index += 1,
160                    }
161                }
162                if !children.0.is_empty() {
163                    // Short commands should not pay a scheduler tick at every
164                    // task boundary. Long builds retain the low polling rate.
165                    let interval = if launched.elapsed() < Duration::from_millis(250) {
166                        1
167                    } else {
168                        20
169                    };
170                    std::thread::sleep(Duration::from_millis(interval));
171                }
172            }
173        })();
174        for reader in readers {
175            reader
176                .join()
177                .map_err(|_| anyhow::anyhow!("output capture thread panicked"))??;
178        }
179        if let (Some(capture), Ok(outcome)) = (capture, &outcome) {
180            capture.finish(if *outcome == Outcome::Success {
181                Shown::Success
182            } else {
183                Shown::Failure
184            })?;
185        }
186        outcome
187    })
188}
189
190/// A command running `text` in the shell tasks run in: `/bin/sh` on Unix,
191/// `cmd.exe` on Windows.
192pub fn shell(text: &str) -> Command {
193    #[cfg(windows)]
194    {
195        use std::os::windows::process::CommandExt;
196        let mut command =
197            Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()));
198        command
199            .args(["/D", "/S", "/C"])
200            .raw_arg(format!("\"{text}\""));
201        command
202    }
203    #[cfg(not(windows))]
204    {
205        let mut command = Command::new("/bin/sh");
206        command.arg("-c").arg(text);
207        command
208    }
209}
210
211/// Launch a task command with its environment, confinement and output streams.
212fn spawn(
213    task: &PreparedTask,
214    text: &str,
215    index: usize,
216    capture: bool,
217    interactive: bool,
218) -> Result<GroupChild> {
219    #[cfg(windows)]
220    let mut command = shell(text);
221    #[cfg(not(windows))]
222    let mut command = match &task.sandbox {
223        Some(crate::Confinement::Seatbelt(profile)) => {
224            let mut command = Command::new("sandbox-exec");
225            command.arg("-f").arg(profile).args(["/bin/sh", "-c", text]);
226            command
227        }
228        _ => shell(text),
229    };
230    if let Some(recording) = &task.recording {
231        command = recording.command(index, text);
232    }
233    #[cfg(target_os = "linux")]
234    if let Some(crate::Confinement::Landlock(ruleset)) = task.sandbox {
235        use std::os::unix::process::CommandExt;
236        // Between fork and exec: only system calls.
237        unsafe {
238            command.pre_exec(move || {
239                if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0
240                    || libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0u32) != 0
241                {
242                    return Err(std::io::Error::last_os_error());
243                }
244                Ok(())
245            });
246        }
247    }
248    command
249        .current_dir(&task.cwd)
250        .env_clear()
251        .envs(&task.env)
252        .envs(&task.execution)
253        .stdin(if interactive {
254            Stdio::inherit()
255        } else {
256            Stdio::null()
257        })
258        .stdout(if capture {
259            Stdio::piped()
260        } else {
261            Stdio::inherit()
262        })
263        .stderr(if capture {
264            Stdio::piped()
265        } else {
266            Stdio::inherit()
267        });
268    if let Some(node) = &task.node_path
269        && let Some((key, path)) = node.path(&task.env)?
270    {
271        command.env(key, path);
272    }
273    // Re-entered exec inherits confinement. Override any caller-supplied
274    // marker so an unsandboxed task cannot accidentally claim confinement.
275    command.env_remove("QK_TASK_SANDBOX");
276    if task.sandbox.is_some() {
277        command.env("QK_TASK_SANDBOX", "1");
278    }
279    #[cfg(unix)]
280    if interactive && terminal::in_foreground() {
281        use std::os::unix::process::CommandExt;
282        // Each command runs in its own process group, which the terminal
283        // stops when it reads; so the command makes its group the terminal's
284        // foreground before it starts, as a shell does for a job.
285        unsafe {
286            command.pre_exec(|| {
287                terminal::claim();
288                Ok(())
289            });
290        }
291    }
292    Ok(command.group_spawn()?)
293}
294
295/// Handing the terminal to an interactive command and back.
296mod terminal {
297    /// Whether stdin is a terminal whose foreground is qk's process group.
298    #[cfg_attr(not(unix), allow(dead_code))]
299    pub fn in_foreground() -> bool {
300        #[cfg(unix)]
301        unsafe {
302            libc::isatty(0) == 1 && libc::tcgetpgrp(0) == libc::getpgrp()
303        }
304        #[cfg(not(unix))]
305        false
306    }
307
308    /// Makes the calling process's group the terminal's foreground. Run in
309    /// the child between fork and exec, so only async-signal-safe calls.
310    #[cfg(unix)]
311    pub fn claim() {
312        unsafe { foreground(libc::getpgrp()) }
313    }
314
315    /// `tcsetpgrp` from a background group raises SIGTTOU, which would stop
316    /// the caller; it is ignored around the call.
317    #[cfg(unix)]
318    unsafe fn foreground(group: libc::pid_t) {
319        unsafe {
320            let previous = libc::signal(libc::SIGTTOU, libc::SIG_IGN);
321            libc::tcsetpgrp(0, group);
322            libc::signal(libc::SIGTTOU, previous);
323        }
324    }
325
326    /// Returns the terminal to qk's group when dropped, if qk had it.
327    pub struct Foreground {
328        #[cfg(unix)]
329        group: Option<libc::pid_t>,
330    }
331
332    impl Foreground {
333        pub fn take_back_on_drop() -> Self {
334            Self {
335                #[cfg(unix)]
336                group: in_foreground().then(|| unsafe { libc::getpgrp() }),
337            }
338        }
339    }
340
341    impl Drop for Foreground {
342        fn drop(&mut self) {
343            #[cfg(unix)]
344            if let Some(group) = self.group {
345                unsafe { foreground(group) }
346            }
347        }
348    }
349}
350
351fn exit_code(status: std::process::ExitStatus) -> i32 {
352    #[cfg(unix)]
353    {
354        use std::os::unix::process::ExitStatusExt;
355        status
356            .code()
357            .unwrap_or_else(|| 128 + status.signal().unwrap_or(1))
358    }
359    #[cfg(not(unix))]
360    {
361        status.code().unwrap_or(1)
362    }
363}