Skip to main content

qk_executor/
process.rs

1use std::collections::BTreeSet;
2use std::process::{Command, ExitStatus, 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/// A running command: in a process group of its own or, under a
20/// pseudo-terminal, a session of its own.
21enum Running {
22    Group(GroupChild),
23    #[cfg(unix)]
24    Session(crate::pty::Session),
25}
26
27impl Running {
28    fn id(&self) -> u32 {
29        match self {
30            Self::Group(child) => child.id(),
31            #[cfg(unix)]
32            Self::Session(child) => child.id(),
33        }
34    }
35
36    fn inner(&mut self) -> &mut std::process::Child {
37        match self {
38            Self::Group(child) => child.inner(),
39            #[cfg(unix)]
40            Self::Session(child) => child.inner(),
41        }
42    }
43
44    fn kill(&mut self) -> std::io::Result<()> {
45        match self {
46            Self::Group(child) => child.kill(),
47            #[cfg(unix)]
48            Self::Session(child) => child.kill(),
49        }
50    }
51
52    fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
53        match self {
54            Self::Group(child) => child.try_wait(),
55            #[cfg(unix)]
56            Self::Session(child) => child.try_wait(),
57        }
58    }
59
60    fn wait(&mut self) -> std::io::Result<ExitStatus> {
61        match self {
62            Self::Group(child) => child.wait(),
63            #[cfg(unix)]
64            Self::Session(child) => child.wait(),
65        }
66    }
67
68    #[cfg(unix)]
69    fn terminate(&self) {
70        use command_group::{Signal, UnixChildExt};
71        let _ = match self {
72            Self::Group(child) => child.signal(Signal::SIGTERM),
73            Self::Session(child) => child.signal(libc::SIGTERM),
74        };
75    }
76}
77
78/// The running commands, whose ids the task publishes while they run.
79struct Children<'a>(Vec<Running>, &'a Mutex<BTreeSet<u32>>);
80
81impl Children<'_> {
82    fn push(&mut self, child: Running) {
83        self.1.lock().unwrap().insert(child.id());
84        self.0.push(child);
85    }
86
87    fn remove(&mut self, index: usize) -> Running {
88        let child = self.0.swap_remove(index);
89        self.1.lock().unwrap().remove(&child.id());
90        child
91    }
92}
93
94impl Drop for Children<'_> {
95    fn drop(&mut self) {
96        // Also clean up when spawning or polling one command fails.
97        for child in &mut self.0 {
98            let _ = child.kill();
99        }
100        for child in &mut self.0 {
101            let _ = child.wait();
102        }
103        let mut ids = self.1.lock().unwrap();
104        for child in &self.0 {
105            ids.remove(&child.id());
106        }
107    }
108}
109
110/// How long a cancelled or stopped command may take to exit after SIGTERM:
111/// NX_PROCESS_KILL_GRACE_PERIOD milliseconds, 5000 by default, as in Nx.
112#[cfg(unix)]
113fn grace() -> Duration {
114    grace_period(
115        std::env::var("NX_PROCESS_KILL_GRACE_PERIOD")
116            .ok()
117            .as_deref(),
118    )
119}
120
121#[cfg(unix)]
122fn grace_period(value: Option<&str>) -> Duration {
123    Duration::from_millis(
124        value
125            .and_then(|value| value.parse::<u32>().ok())
126            .map_or(5000, u64::from),
127    )
128}
129
130impl Children<'_> {
131    /// Asks every process group to exit, then leaves stragglers to `Drop`.
132    fn terminate(&mut self) {
133        #[cfg(unix)]
134        {
135            for child in &self.0 {
136                child.terminate();
137            }
138            let deadline = std::time::Instant::now() + grace();
139            while std::time::Instant::now() < deadline
140                && self
141                    .0
142                    .iter_mut()
143                    .any(|child| matches!(child.try_wait(), Ok(None)))
144            {
145                std::thread::sleep(Duration::from_millis(20));
146            }
147        }
148    }
149}
150
151pub fn execute(task: &PreparedTask, cancelled: &AtomicBool) -> Result<Outcome> {
152    let log_matches = match &task.ready_check {
153        Some(Ok(crate::readiness::ReadyCheck {
154            probe: crate::readiness::Probe::LogMatches(patterns),
155            ..
156        })) => Some(patterns),
157        _ => None,
158    };
159    if task.display == Display::Stream
160        && task.ready_when.is_empty()
161        && log_matches.is_none()
162        && task.decorations.iter().all(Decoration::is_empty)
163    {
164        // Straight through: the task writes to the terminal itself.
165        return execute_captured(task, cancelled, None);
166    }
167    let mut capture =
168        Capture::new(None, task.display.clone()).ready_when(&task.ready_when, task.ready.clone());
169    if let Some(patterns) = log_matches {
170        capture = capture.log_matches(patterns, task.verdict.clone());
171    }
172    execute_captured(task, cancelled, Some(&capture))
173}
174
175pub fn execute_captured(
176    task: &PreparedTask,
177    cancelled: &AtomicBool,
178    capture: Option<&Capture>,
179) -> Result<Outcome> {
180    // Takes the terminal back from an interactive command, however it ends.
181    let _terminal = (task.interactive && task.commands.len() == 1)
182        .then(terminal::Foreground::take_back_on_drop);
183    std::thread::scope(|scope| {
184        let mut readers = Vec::new();
185        let outcome = (|| {
186            // Dropped after `children`, once the interactive command is gone.
187            #[cfg(unix)]
188            let mut forwarders = Vec::new();
189            let mut pending = task.commands.iter().enumerate();
190            let mut children = Children(Vec::new(), &task.processes);
191            let mut launched = Instant::now();
192            loop {
193                if cancelled.load(Ordering::SeqCst) {
194                    children.terminate();
195                    return Ok(Outcome::Cancelled);
196                }
197                if children.0.is_empty() || task.parallel {
198                    for (index, command) in pending.by_ref() {
199                        let interactive = task.interactive && task.commands.len() == 1;
200                        let (mut child, leader) =
201                            spawn(task, command, index, capture.is_some(), interactive)
202                                .with_context(|| format!("cannot start command for {}", task.id))?;
203                        if let Some(capture) = capture {
204                            let decoration = task.decorations.get(index);
205                            match leader {
206                                // One stream, as the terminal merges them.
207                                #[cfg(unix)]
208                                Some(leader) => {
209                                    if interactive && crate::pty::forwards_input() {
210                                        forwarders.push(crate::pty::Forwarder::start(
211                                            leader.try_clone()?,
212                                            task.tty,
213                                        ));
214                                    }
215                                    let output = crate::pty::Output(leader);
216                                    readers
217                                        .push(scope.spawn(move || {
218                                            capture.copy(output, false, decoration)
219                                        }));
220                                }
221                                _ => {
222                                    let stdout = child
223                                        .inner()
224                                        .stdout
225                                        .take()
226                                        .context("missing stdout pipe")?;
227                                    let stderr = child
228                                        .inner()
229                                        .stderr
230                                        .take()
231                                        .context("missing stderr pipe")?;
232                                    readers
233                                        .push(scope.spawn(move || {
234                                            capture.copy(stdout, false, decoration)
235                                        }));
236                                    readers.push(
237                                        scope.spawn(move || capture.copy(stderr, true, decoration)),
238                                    );
239                                }
240                            }
241                        }
242                        children.push(child);
243                        launched = Instant::now();
244                        if !task.parallel {
245                            break;
246                        }
247                    }
248                }
249                if children.0.is_empty() {
250                    return Ok(Outcome::Success);
251                }
252                let mut index = 0;
253                while index < children.0.len() {
254                    match children.0[index]
255                        .try_wait()
256                        .context("cannot wait for command")?
257                    {
258                        Some(status) => {
259                            let mut child = children.remove(index);
260                            // Finite tasks may not leave background descendants running.
261                            let _ = child.kill();
262                            if !status.success() {
263                                // As in Nx, commands run side by side fail the
264                                // task with 1, one after another with the code.
265                                let code = if task.parallel
266                                    && task.commands.len() > 1
267                                    && task.ready_when.is_empty()
268                                {
269                                    1
270                                } else {
271                                    exit_code(status)
272                                };
273                                return Ok(Outcome::Failed(code));
274                            }
275                        }
276                        None => index += 1,
277                    }
278                }
279                if !children.0.is_empty() {
280                    // Short commands should not pay a scheduler tick at every
281                    // task boundary. Long builds retain the low polling rate.
282                    let interval = if launched.elapsed() < Duration::from_millis(250) {
283                        1
284                    } else {
285                        20
286                    };
287                    std::thread::sleep(Duration::from_millis(interval));
288                }
289            }
290        })();
291        for reader in readers {
292            reader
293                .join()
294                .map_err(|_| anyhow::anyhow!("output capture thread panicked"))??;
295        }
296        if let (Some(capture), Ok(outcome)) = (capture, &outcome) {
297            capture.finish(if *outcome == Outcome::Success {
298                Shown::Success
299            } else {
300                Shown::Failure
301            })?;
302        }
303        outcome
304    })
305}
306
307/// A command running `text` in the shell tasks run in: `/bin/sh` on Unix,
308/// `cmd.exe` on Windows.
309pub fn shell(text: &str) -> Command {
310    #[cfg(windows)]
311    {
312        use std::os::windows::process::CommandExt;
313        let mut command =
314            Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()));
315        command
316            .args(["/D", "/S", "/C"])
317            .raw_arg(format!("\"{text}\""));
318        command
319    }
320    #[cfg(not(windows))]
321    {
322        let mut command = Command::new("/bin/sh");
323        command.arg("-c").arg(text);
324        command
325    }
326}
327
328/// Launch a task command with its environment, confinement and output streams.
329/// A captured command runs under a pseudo-terminal when qk's output is a
330/// terminal, as in Nx; the terminal's leader end is returned with it.
331fn spawn(
332    task: &PreparedTask,
333    text: &str,
334    index: usize,
335    capture: bool,
336    interactive: bool,
337) -> Result<(Running, Option<std::fs::File>)> {
338    // Checked here rather than when the task is prepared: a dependency may
339    // create the directory, as a native prebuild creates the project a build
340    // runs in.
341    if !task.cwd.is_dir() {
342        anyhow::bail!("working directory does not exist: {}", task.cwd.display());
343    }
344    #[cfg(windows)]
345    let mut command = shell(text);
346    #[cfg(not(windows))]
347    let mut command = match &task.sandbox {
348        Some(crate::Confinement::Seatbelt(profile)) => {
349            let mut command = Command::new("sandbox-exec");
350            command.arg("-f").arg(profile).args(["/bin/sh", "-c", text]);
351            command
352        }
353        _ => shell(text),
354    };
355    if let Some(recording) = &task.recording {
356        command = recording.command(index, text);
357    }
358    #[cfg(target_os = "linux")]
359    if let Some(crate::Confinement::Landlock(ruleset)) = task.sandbox {
360        use std::os::unix::process::CommandExt;
361        // Between fork and exec: only system calls.
362        unsafe {
363            command.pre_exec(move || {
364                if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0
365                    || libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0u32) != 0
366                {
367                    return Err(std::io::Error::last_os_error());
368                }
369                Ok(())
370            });
371        }
372    }
373    command
374        .current_dir(&task.cwd)
375        .env_clear()
376        .envs(&task.env)
377        .envs(&task.execution)
378        .stdin(if interactive {
379            Stdio::inherit()
380        } else {
381            Stdio::null()
382        })
383        .stdout(if capture {
384            Stdio::piped()
385        } else {
386            Stdio::inherit()
387        })
388        .stderr(if capture {
389            Stdio::piped()
390        } else {
391            Stdio::inherit()
392        });
393    if let Some(node) = &task.node_path
394        && let Some((key, path)) = node.path(&task.env)?
395    {
396        command.env(key, path);
397    }
398    // Re-entered exec inherits confinement. Override any caller-supplied
399    // marker so an unsandboxed task cannot accidentally claim confinement.
400    command.env_remove("QK_TASK_SANDBOX");
401    if task.sandbox.is_some() {
402        command.env("QK_TASK_SANDBOX", "1");
403    }
404    #[cfg(unix)]
405    if capture
406        && task.pseudo_terminal
407        // As in Nx, prefixed lines are not drawn on a terminal.
408        && task
409            .decorations
410            .get(index)
411            .is_none_or(|decoration| decoration.prefix.is_none())
412        && std::io::IsTerminal::is_terminal(&std::io::stdout())
413    {
414        let input = interactive && crate::pty::forwards_input();
415        let (session, leader) = crate::pty::spawn(&mut command, input)?;
416        return Ok((Running::Session(session), Some(leader)));
417    }
418    #[cfg(unix)]
419    if interactive && terminal::in_foreground() {
420        use std::os::unix::process::CommandExt;
421        // Each command runs in its own process group, which the terminal
422        // stops when it reads; so the command makes its group the terminal's
423        // foreground before it starts, as a shell does for a job.
424        unsafe {
425            command.pre_exec(|| {
426                terminal::claim();
427                Ok(())
428            });
429        }
430    }
431    Ok((Running::Group(command.group_spawn()?), None))
432}
433
434/// Handing the terminal to an interactive command and back.
435pub(crate) mod terminal {
436    /// Whether stdin is a terminal whose foreground is qk's process group.
437    #[cfg_attr(not(unix), allow(dead_code))]
438    pub fn in_foreground() -> bool {
439        #[cfg(unix)]
440        unsafe {
441            libc::isatty(0) == 1 && libc::tcgetpgrp(0) == libc::getpgrp()
442        }
443        #[cfg(not(unix))]
444        false
445    }
446
447    /// Makes the calling process's group the terminal's foreground. Run in
448    /// the child between fork and exec, so only async-signal-safe calls.
449    #[cfg(unix)]
450    pub fn claim() {
451        unsafe { foreground(libc::getpgrp()) }
452    }
453
454    /// `tcsetpgrp` from a background group raises SIGTTOU, which would stop
455    /// the caller; it is ignored around the call.
456    #[cfg(unix)]
457    unsafe fn foreground(group: libc::pid_t) {
458        unsafe {
459            let previous = libc::signal(libc::SIGTTOU, libc::SIG_IGN);
460            libc::tcsetpgrp(0, group);
461            libc::signal(libc::SIGTTOU, previous);
462        }
463    }
464
465    /// Returns the terminal to qk's group when dropped, if qk had it.
466    pub struct Foreground {
467        #[cfg(unix)]
468        group: Option<libc::pid_t>,
469    }
470
471    impl Foreground {
472        pub fn take_back_on_drop() -> Self {
473            Self {
474                #[cfg(unix)]
475                group: in_foreground().then(|| unsafe { libc::getpgrp() }),
476            }
477        }
478    }
479
480    impl Drop for Foreground {
481        fn drop(&mut self) {
482            #[cfg(unix)]
483            if let Some(group) = self.group {
484                unsafe { foreground(group) }
485            }
486        }
487    }
488}
489
490fn exit_code(status: std::process::ExitStatus) -> i32 {
491    #[cfg(unix)]
492    {
493        use std::os::unix::process::ExitStatusExt;
494        status
495            .code()
496            .unwrap_or_else(|| 128 + status.signal().unwrap_or(1))
497    }
498    #[cfg(not(unix))]
499    {
500        status.code().unwrap_or(1)
501    }
502}
503
504#[cfg(all(test, unix))]
505mod grace_tests {
506    use super::*;
507
508    #[test]
509    fn the_kill_grace_period_is_read_as_nx_reads_it() {
510        assert_eq!(grace_period(None), Duration::from_millis(5000));
511        assert_eq!(grace_period(Some("250")), Duration::from_millis(250));
512        assert_eq!(grace_period(Some("soon")), Duration::from_millis(5000));
513    }
514}