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        // As in Nx, commands that all exit without printing their readyWhen
297        // text fail the task: it never became ready, and nothing waiting on
298        // it would have started.
299        let outcome = match (capture, outcome) {
300            (Some(capture), Ok(Outcome::Success)) => {
301                let missing = capture.unready();
302                if missing.is_empty() {
303                    Ok(Outcome::Success)
304                } else {
305                    let exited = match task.commands.as_slice() {
306                        [command] => format!("command \"{command}\""),
307                        _ => "all commands".to_owned(),
308                    };
309                    let missing = missing
310                        .iter()
311                        .map(|text| format!("\"{text}\""))
312                        .collect::<Vec<_>>()
313                        .join(", ");
314                    capture.note(&format!(
315                        "Warning: {exited} exited before printing the readyWhen text {missing}"
316                    ))?;
317                    Ok(Outcome::Failed(1))
318                }
319            }
320            (_, outcome) => outcome,
321        };
322        if let (Some(capture), Ok(outcome)) = (capture, &outcome) {
323            capture.finish(if *outcome == Outcome::Success {
324                Shown::Success
325            } else {
326                Shown::Failure
327            })?;
328        }
329        outcome
330    })
331}
332
333/// A command running `text` in the shell tasks run in: `/bin/sh` on Unix,
334/// `cmd.exe` on Windows.
335pub fn shell(text: &str) -> Command {
336    #[cfg(windows)]
337    {
338        use std::os::windows::process::CommandExt;
339        let mut command =
340            Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()));
341        command
342            .args(["/D", "/S", "/C"])
343            .raw_arg(format!("\"{text}\""));
344        command
345    }
346    #[cfg(not(windows))]
347    {
348        let mut command = Command::new("/bin/sh");
349        command.arg("-c").arg(text);
350        command
351    }
352}
353
354/// Launch a task command with its environment, confinement and output streams.
355/// A captured command runs under a pseudo-terminal when qk's output is a
356/// terminal, as in Nx; the terminal's leader end is returned with it.
357fn spawn(
358    task: &PreparedTask,
359    text: &str,
360    index: usize,
361    capture: bool,
362    interactive: bool,
363) -> Result<(Running, Option<std::fs::File>)> {
364    // Checked here rather than when the task is prepared: a dependency may
365    // create the directory, as a native prebuild creates the project a build
366    // runs in.
367    if !task.cwd.is_dir() {
368        anyhow::bail!("working directory does not exist: {}", task.cwd.display());
369    }
370    #[cfg(windows)]
371    let mut command = shell(text);
372    #[cfg(not(windows))]
373    let mut command = match &task.sandbox {
374        Some(crate::Confinement::Seatbelt(profile)) => {
375            let mut command = Command::new("sandbox-exec");
376            command.arg("-f").arg(profile).args(["/bin/sh", "-c", text]);
377            command
378        }
379        _ => shell(text),
380    };
381    if let Some(recording) = &task.recording {
382        command = recording.command(index, text, &task.cwd);
383    }
384    #[cfg(target_os = "linux")]
385    if let Some(crate::Confinement::Landlock(ruleset)) = task.sandbox {
386        use std::os::unix::process::CommandExt;
387        // Between fork and exec: only system calls.
388        unsafe {
389            command.pre_exec(move || {
390                if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0
391                    || libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0u32) != 0
392                {
393                    return Err(std::io::Error::last_os_error());
394                }
395                Ok(())
396            });
397        }
398    }
399    command
400        .current_dir(&task.cwd)
401        .env_clear()
402        .envs(&task.env)
403        .envs(&task.execution)
404        .stdin(if interactive {
405            Stdio::inherit()
406        } else {
407            Stdio::null()
408        })
409        .stdout(if capture {
410            Stdio::piped()
411        } else {
412            Stdio::inherit()
413        })
414        .stderr(if capture {
415            Stdio::piped()
416        } else {
417            Stdio::inherit()
418        });
419    if let Some(node) = &task.node_path
420        && let Some((key, path)) = node.path(&task.env)?
421    {
422        command.env(key, path);
423    }
424    // Re-entered exec inherits confinement. Override any caller-supplied
425    // marker so an unsandboxed task cannot accidentally claim confinement.
426    command.env_remove("QK_TASK_SANDBOX");
427    if task.sandbox.is_some() {
428        command.env("QK_TASK_SANDBOX", "1");
429    }
430    #[cfg(unix)]
431    if capture
432        && task.pseudo_terminal
433        // As in Nx, prefixed lines are not drawn on a terminal.
434        && task
435            .decorations
436            .get(index)
437            .is_none_or(|decoration| decoration.prefix.is_none())
438        && std::io::IsTerminal::is_terminal(&std::io::stdout())
439    {
440        let input = interactive && crate::pty::forwards_input();
441        let (session, leader) = crate::pty::spawn(&mut command, input)?;
442        return Ok((Running::Session(session), Some(leader)));
443    }
444    #[cfg(unix)]
445    if interactive && terminal::in_foreground() {
446        use std::os::unix::process::CommandExt;
447        // Each command runs in its own process group, which the terminal
448        // stops when it reads; so the command makes its group the terminal's
449        // foreground before it starts, as a shell does for a job.
450        unsafe {
451            command.pre_exec(|| {
452                terminal::claim();
453                Ok(())
454            });
455        }
456    }
457    Ok((Running::Group(command.group_spawn()?), None))
458}
459
460/// Handing the terminal to an interactive command and back.
461pub(crate) mod terminal {
462    /// Whether stdin is a terminal whose foreground is qk's process group.
463    #[cfg_attr(not(unix), allow(dead_code))]
464    pub fn in_foreground() -> bool {
465        #[cfg(unix)]
466        unsafe {
467            libc::isatty(0) == 1 && libc::tcgetpgrp(0) == libc::getpgrp()
468        }
469        #[cfg(not(unix))]
470        false
471    }
472
473    /// Makes the calling process's group the terminal's foreground. Run in
474    /// the child between fork and exec, so only async-signal-safe calls.
475    #[cfg(unix)]
476    pub fn claim() {
477        unsafe { foreground(libc::getpgrp()) }
478    }
479
480    /// `tcsetpgrp` from a background group raises SIGTTOU, which would stop
481    /// the caller; it is ignored around the call.
482    #[cfg(unix)]
483    unsafe fn foreground(group: libc::pid_t) {
484        unsafe {
485            let previous = libc::signal(libc::SIGTTOU, libc::SIG_IGN);
486            libc::tcsetpgrp(0, group);
487            libc::signal(libc::SIGTTOU, previous);
488        }
489    }
490
491    /// Returns the terminal to qk's group when dropped, if qk had it.
492    pub struct Foreground {
493        #[cfg(unix)]
494        group: Option<libc::pid_t>,
495    }
496
497    impl Foreground {
498        pub fn take_back_on_drop() -> Self {
499            Self {
500                #[cfg(unix)]
501                group: in_foreground().then(|| unsafe { libc::getpgrp() }),
502            }
503        }
504    }
505
506    impl Drop for Foreground {
507        fn drop(&mut self) {
508            #[cfg(unix)]
509            if let Some(group) = self.group {
510                unsafe { foreground(group) }
511            }
512        }
513    }
514}
515
516fn exit_code(status: std::process::ExitStatus) -> i32 {
517    #[cfg(unix)]
518    {
519        use std::os::unix::process::ExitStatusExt;
520        status
521            .code()
522            .unwrap_or_else(|| 128 + status.signal().unwrap_or(1))
523    }
524    #[cfg(not(unix))]
525    {
526        status.code().unwrap_or(1)
527    }
528}
529
530#[cfg(all(test, unix))]
531mod grace_tests {
532    use super::*;
533
534    #[test]
535    fn the_kill_grace_period_is_read_as_nx_reads_it() {
536        assert_eq!(grace_period(None), Duration::from_millis(5000));
537        assert_eq!(grace_period(Some("250")), Duration::from_millis(250));
538        assert_eq!(grace_period(Some("soon")), Duration::from_millis(5000));
539    }
540}