Skip to main content

qk_executor/
process.rs

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