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    // Checked here rather than when the task is prepared: a dependency may
220    // create the directory, as a native prebuild creates the project a build
221    // runs in.
222    if !task.cwd.is_dir() {
223        anyhow::bail!("working directory does not exist: {}", task.cwd.display());
224    }
225    #[cfg(windows)]
226    let mut command = shell(text);
227    #[cfg(not(windows))]
228    let mut command = match &task.sandbox {
229        Some(crate::Confinement::Seatbelt(profile)) => {
230            let mut command = Command::new("sandbox-exec");
231            command.arg("-f").arg(profile).args(["/bin/sh", "-c", text]);
232            command
233        }
234        _ => shell(text),
235    };
236    if let Some(recording) = &task.recording {
237        command = recording.command(index, text);
238    }
239    #[cfg(target_os = "linux")]
240    if let Some(crate::Confinement::Landlock(ruleset)) = task.sandbox {
241        use std::os::unix::process::CommandExt;
242        // Between fork and exec: only system calls.
243        unsafe {
244            command.pre_exec(move || {
245                if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0
246                    || libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0u32) != 0
247                {
248                    return Err(std::io::Error::last_os_error());
249                }
250                Ok(())
251            });
252        }
253    }
254    command
255        .current_dir(&task.cwd)
256        .env_clear()
257        .envs(&task.env)
258        .envs(&task.execution)
259        .stdin(if interactive {
260            Stdio::inherit()
261        } else {
262            Stdio::null()
263        })
264        .stdout(if capture {
265            Stdio::piped()
266        } else {
267            Stdio::inherit()
268        })
269        .stderr(if capture {
270            Stdio::piped()
271        } else {
272            Stdio::inherit()
273        });
274    if let Some(node) = &task.node_path
275        && let Some((key, path)) = node.path(&task.env)?
276    {
277        command.env(key, path);
278    }
279    // Re-entered exec inherits confinement. Override any caller-supplied
280    // marker so an unsandboxed task cannot accidentally claim confinement.
281    command.env_remove("QK_TASK_SANDBOX");
282    if task.sandbox.is_some() {
283        command.env("QK_TASK_SANDBOX", "1");
284    }
285    #[cfg(unix)]
286    if interactive && terminal::in_foreground() {
287        use std::os::unix::process::CommandExt;
288        // Each command runs in its own process group, which the terminal
289        // stops when it reads; so the command makes its group the terminal's
290        // foreground before it starts, as a shell does for a job.
291        unsafe {
292            command.pre_exec(|| {
293                terminal::claim();
294                Ok(())
295            });
296        }
297    }
298    Ok(command.group_spawn()?)
299}
300
301/// Handing the terminal to an interactive command and back.
302mod terminal {
303    /// Whether stdin is a terminal whose foreground is qk's process group.
304    #[cfg_attr(not(unix), allow(dead_code))]
305    pub fn in_foreground() -> bool {
306        #[cfg(unix)]
307        unsafe {
308            libc::isatty(0) == 1 && libc::tcgetpgrp(0) == libc::getpgrp()
309        }
310        #[cfg(not(unix))]
311        false
312    }
313
314    /// Makes the calling process's group the terminal's foreground. Run in
315    /// the child between fork and exec, so only async-signal-safe calls.
316    #[cfg(unix)]
317    pub fn claim() {
318        unsafe { foreground(libc::getpgrp()) }
319    }
320
321    /// `tcsetpgrp` from a background group raises SIGTTOU, which would stop
322    /// the caller; it is ignored around the call.
323    #[cfg(unix)]
324    unsafe fn foreground(group: libc::pid_t) {
325        unsafe {
326            let previous = libc::signal(libc::SIGTTOU, libc::SIG_IGN);
327            libc::tcsetpgrp(0, group);
328            libc::signal(libc::SIGTTOU, previous);
329        }
330    }
331
332    /// Returns the terminal to qk's group when dropped, if qk had it.
333    pub struct Foreground {
334        #[cfg(unix)]
335        group: Option<libc::pid_t>,
336    }
337
338    impl Foreground {
339        pub fn take_back_on_drop() -> Self {
340            Self {
341                #[cfg(unix)]
342                group: in_foreground().then(|| unsafe { libc::getpgrp() }),
343            }
344        }
345    }
346
347    impl Drop for Foreground {
348        fn drop(&mut self) {
349            #[cfg(unix)]
350            if let Some(group) = self.group {
351                unsafe { foreground(group) }
352            }
353        }
354    }
355}
356
357fn exit_code(status: std::process::ExitStatus) -> i32 {
358    #[cfg(unix)]
359    {
360        use std::os::unix::process::ExitStatusExt;
361        status
362            .code()
363            .unwrap_or_else(|| 128 + status.signal().unwrap_or(1))
364    }
365    #[cfg(not(unix))]
366    {
367        status.code().unwrap_or(1)
368    }
369}