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
175/// Empties each of `directories` before a task runs, making those that do not
176/// exist yet: a task's scratch directories, which it keeps nothing in
177/// between runs. Each is checked for links once more as it is emptied, as an
178/// earlier task may have made one since the run was prepared: emptying it
179/// could follow a link out of the workspace.
180pub fn empty_scratch(directories: &[crate::Scratch]) -> std::io::Result<()> {
181    for crate::Scratch { root, directory } in directories {
182        let relative = directory
183            .strip_prefix(root)
184            .ok()
185            .filter(|relative| {
186                relative
187                    .components()
188                    .all(|part| matches!(part, std::path::Component::Normal(_)))
189            })
190            .ok_or_else(|| {
191                std::io::Error::other(format!(
192                    "{} is no path inside {}",
193                    directory.display(),
194                    root.display()
195                ))
196            })?;
197        let mut path = root.clone();
198        for part in relative.components() {
199            path.push(part);
200            match std::fs::symlink_metadata(&path) {
201                Ok(metadata) if metadata.is_symlink() => {
202                    return Err(std::io::Error::other(format!(
203                        "{} is a symbolic link, which emptying {} could follow",
204                        path.display(),
205                        directory.display()
206                    )));
207                }
208                Ok(_) => {}
209                Err(_) => break,
210            }
211        }
212        match std::fs::symlink_metadata(directory) {
213            Ok(metadata) if metadata.is_dir() => {
214                for entry in std::fs::read_dir(directory)? {
215                    let entry = entry?;
216                    if entry.file_type()?.is_dir() {
217                        std::fs::remove_dir_all(entry.path())?;
218                    } else {
219                        std::fs::remove_file(entry.path())?;
220                    }
221                }
222            }
223            Ok(_) => {
224                std::fs::remove_file(directory)?;
225                std::fs::create_dir_all(directory)?;
226            }
227            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
228                std::fs::create_dir_all(directory)?;
229            }
230            Err(error) => return Err(error),
231        }
232    }
233    Ok(())
234}
235
236pub fn execute_captured(
237    task: &PreparedTask,
238    cancelled: &AtomicBool,
239    capture: Option<&Capture>,
240) -> Result<Outcome> {
241    empty_scratch(&task.scratch).context("cannot empty its scratch directories")?;
242    // Takes the terminal back from an interactive command, however it ends.
243    let _terminal = (task.interactive && task.commands.len() == 1)
244        .then(terminal::Foreground::take_back_on_drop);
245    std::thread::scope(|scope| {
246        let mut readers = Vec::new();
247        let outcome = (|| {
248            // Dropped after `children`, once the interactive command is gone.
249            #[cfg(unix)]
250            let mut forwarders = Vec::new();
251            let mut pending = task.commands.iter().enumerate();
252            let mut children = Children(Vec::new(), &task.processes);
253            let mut launched = Instant::now();
254            loop {
255                if cancelled.load(Ordering::SeqCst) {
256                    children.terminate();
257                    return Ok(Outcome::Cancelled);
258                }
259                if children.0.is_empty() || task.parallel {
260                    for (index, command) in pending.by_ref() {
261                        let interactive = task.interactive && task.commands.len() == 1;
262                        let (mut child, leader) =
263                            spawn(task, command, index, capture.is_some(), interactive)
264                                .with_context(|| format!("cannot start command for {}", task.id))?;
265                        if let Some(capture) = capture {
266                            let decoration = task.decorations.get(index);
267                            match leader {
268                                // One stream, as the terminal merges them.
269                                #[cfg(unix)]
270                                Some(leader) => {
271                                    if interactive && crate::pty::forwards_input() {
272                                        forwarders.push(crate::pty::Forwarder::start(
273                                            leader.try_clone()?,
274                                            task.tty,
275                                        ));
276                                    }
277                                    let output = crate::pty::Output(leader);
278                                    readers
279                                        .push(scope.spawn(move || {
280                                            capture.copy(output, false, decoration)
281                                        }));
282                                }
283                                _ => {
284                                    let stdout = child
285                                        .inner()
286                                        .stdout
287                                        .take()
288                                        .context("missing stdout pipe")?;
289                                    let stderr = child
290                                        .inner()
291                                        .stderr
292                                        .take()
293                                        .context("missing stderr pipe")?;
294                                    readers
295                                        .push(scope.spawn(move || {
296                                            capture.copy(stdout, false, decoration)
297                                        }));
298                                    readers.push(
299                                        scope.spawn(move || capture.copy(stderr, true, decoration)),
300                                    );
301                                }
302                            }
303                        }
304                        children.push(child);
305                        launched = Instant::now();
306                        if !task.parallel {
307                            break;
308                        }
309                    }
310                }
311                if children.0.is_empty() {
312                    return Ok(Outcome::Success);
313                }
314                let mut index = 0;
315                while index < children.0.len() {
316                    match children.0[index]
317                        .try_wait()
318                        .context("cannot wait for command")?
319                    {
320                        Some(status) => {
321                            let mut child = children.remove(index);
322                            // Finite tasks may not leave background descendants running.
323                            let _ = child.kill();
324                            if !status.success() {
325                                // As in Nx, commands run side by side fail the
326                                // task with 1, one after another with the code.
327                                let code = if task.parallel
328                                    && task.commands.len() > 1
329                                    && task.ready_when.is_empty()
330                                {
331                                    1
332                                } else {
333                                    exit_code(status)
334                                };
335                                return Ok(Outcome::Failed(code));
336                            }
337                        }
338                        None => index += 1,
339                    }
340                }
341                if !children.0.is_empty() {
342                    // Short commands should not pay a scheduler tick at every
343                    // task boundary. Long builds retain the low polling rate.
344                    let interval = if launched.elapsed() < Duration::from_millis(250) {
345                        1
346                    } else {
347                        20
348                    };
349                    std::thread::sleep(Duration::from_millis(interval));
350                }
351            }
352        })();
353        for reader in readers {
354            reader
355                .join()
356                .map_err(|_| anyhow::anyhow!("output capture thread panicked"))??;
357        }
358        // As in Nx, commands that all exit without printing their readyWhen
359        // text fail the task: it never became ready, and nothing waiting on
360        // it would have started.
361        let outcome = match (capture, outcome) {
362            (Some(capture), Ok(Outcome::Success)) => {
363                let missing = capture.unready();
364                if missing.is_empty() {
365                    Ok(Outcome::Success)
366                } else {
367                    let exited = match task.commands.as_slice() {
368                        [command] => format!("command \"{command}\""),
369                        _ => "all commands".to_owned(),
370                    };
371                    let missing = missing
372                        .iter()
373                        .map(|text| format!("\"{text}\""))
374                        .collect::<Vec<_>>()
375                        .join(", ");
376                    capture.note(&format!(
377                        "Warning: {exited} exited before printing the readyWhen text {missing}"
378                    ))?;
379                    Ok(Outcome::Failed(1))
380                }
381            }
382            (_, outcome) => outcome,
383        };
384        if let (Some(capture), Ok(outcome)) = (capture, &outcome) {
385            capture.finish(if *outcome == Outcome::Success {
386                Shown::Success
387            } else {
388                Shown::Failure
389            })?;
390        }
391        outcome
392    })
393}
394
395/// A command running `text` in the shell tasks run in: `/bin/sh` on Unix,
396/// `cmd.exe` on Windows.
397pub fn shell(text: &str) -> Command {
398    #[cfg(windows)]
399    {
400        use std::os::windows::process::CommandExt;
401        let mut command =
402            Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()));
403        command
404            .args(["/D", "/S", "/C"])
405            .raw_arg(format!("\"{text}\""));
406        command
407    }
408    #[cfg(not(windows))]
409    {
410        let mut command = Command::new("/bin/sh");
411        command.arg("-c").arg(text);
412        command
413    }
414}
415
416/// Launch a task command with its environment, confinement and output streams.
417/// A captured command runs under a pseudo-terminal when qk's output is a
418/// terminal, as in Nx; the terminal's leader end is returned with it.
419fn spawn(
420    task: &PreparedTask,
421    text: &str,
422    index: usize,
423    capture: bool,
424    interactive: bool,
425) -> Result<(Running, Option<std::fs::File>)> {
426    // Checked here rather than when the task is prepared: a dependency may
427    // create the directory, as a native prebuild creates the project a build
428    // runs in.
429    if !task.cwd.is_dir() {
430        anyhow::bail!("working directory does not exist: {}", task.cwd.display());
431    }
432    #[cfg(windows)]
433    let mut command = shell(text);
434    #[cfg(not(windows))]
435    let mut command = match &task.sandbox {
436        Some(crate::Confinement::Seatbelt(profile)) => {
437            let mut command = Command::new("sandbox-exec");
438            command.arg("-f").arg(profile).args(["/bin/sh", "-c", text]);
439            command
440        }
441        _ => shell(text),
442    };
443    if let Some(recording) = &task.recording {
444        command = recording.command(index, text, &task.cwd);
445    }
446    #[cfg(target_os = "linux")]
447    if let Some(crate::Confinement::Landlock(ruleset)) = task.sandbox {
448        use std::os::unix::process::CommandExt;
449        // Between fork and exec: only system calls.
450        unsafe {
451            command.pre_exec(move || {
452                if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0
453                    || libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0u32) != 0
454                {
455                    return Err(std::io::Error::last_os_error());
456                }
457                Ok(())
458            });
459        }
460    }
461    command
462        .current_dir(&task.cwd)
463        .env_clear()
464        .envs(&task.env)
465        .envs(&task.execution)
466        .stdin(if interactive {
467            Stdio::inherit()
468        } else {
469            Stdio::null()
470        })
471        .stdout(if capture {
472            Stdio::piped()
473        } else {
474            Stdio::inherit()
475        })
476        .stderr(if capture {
477            Stdio::piped()
478        } else {
479            Stdio::inherit()
480        });
481    if let Some(node) = &task.node_path
482        && let Some((key, path)) = node.path(&task.env)?
483    {
484        command.env(key, path);
485    }
486    // Re-entered exec inherits confinement. Override any caller-supplied
487    // marker so an unsandboxed task cannot accidentally claim confinement.
488    command.env_remove("QK_TASK_SANDBOX");
489    if task.sandbox.is_some() {
490        command.env("QK_TASK_SANDBOX", "1");
491    }
492    #[cfg(unix)]
493    if capture
494        && task.pseudo_terminal
495        // As in Nx, prefixed lines are not drawn on a terminal.
496        && task
497            .decorations
498            .get(index)
499            .is_none_or(|decoration| decoration.prefix.is_none())
500        && std::io::IsTerminal::is_terminal(&std::io::stdout())
501    {
502        let input = interactive && crate::pty::forwards_input();
503        let (session, leader) = crate::pty::spawn(&mut command, input)?;
504        return Ok((Running::Session(session), Some(leader)));
505    }
506    #[cfg(unix)]
507    if interactive && terminal::in_foreground() {
508        use std::os::unix::process::CommandExt;
509        // Each command runs in its own process group, which the terminal
510        // stops when it reads; so the command makes its group the terminal's
511        // foreground before it starts, as a shell does for a job.
512        unsafe {
513            command.pre_exec(|| {
514                terminal::claim();
515                Ok(())
516            });
517        }
518    }
519    Ok((Running::Group(command.group_spawn()?), None))
520}
521
522/// Handing the terminal to an interactive command and back.
523pub(crate) mod terminal {
524    /// Whether stdin is a terminal whose foreground is qk's process group.
525    #[cfg_attr(not(unix), allow(dead_code))]
526    pub fn in_foreground() -> bool {
527        #[cfg(unix)]
528        unsafe {
529            libc::isatty(0) == 1 && libc::tcgetpgrp(0) == libc::getpgrp()
530        }
531        #[cfg(not(unix))]
532        false
533    }
534
535    /// Makes the calling process's group the terminal's foreground. Run in
536    /// the child between fork and exec, so only async-signal-safe calls.
537    #[cfg(unix)]
538    pub fn claim() {
539        unsafe { foreground(libc::getpgrp()) }
540    }
541
542    /// `tcsetpgrp` from a background group raises SIGTTOU, which would stop
543    /// the caller; it is ignored around the call.
544    #[cfg(unix)]
545    unsafe fn foreground(group: libc::pid_t) {
546        unsafe {
547            let previous = libc::signal(libc::SIGTTOU, libc::SIG_IGN);
548            libc::tcsetpgrp(0, group);
549            libc::signal(libc::SIGTTOU, previous);
550        }
551    }
552
553    /// Returns the terminal to qk's group when dropped, if qk had it.
554    pub struct Foreground {
555        #[cfg(unix)]
556        group: Option<libc::pid_t>,
557    }
558
559    impl Foreground {
560        pub fn take_back_on_drop() -> Self {
561            Self {
562                #[cfg(unix)]
563                group: in_foreground().then(|| unsafe { libc::getpgrp() }),
564            }
565        }
566    }
567
568    impl Drop for Foreground {
569        fn drop(&mut self) {
570            #[cfg(unix)]
571            if let Some(group) = self.group {
572                unsafe { foreground(group) }
573            }
574        }
575    }
576}
577
578fn exit_code(status: std::process::ExitStatus) -> i32 {
579    #[cfg(unix)]
580    {
581        use std::os::unix::process::ExitStatusExt;
582        status
583            .code()
584            .unwrap_or_else(|| 128 + status.signal().unwrap_or(1))
585    }
586    #[cfg(not(unix))]
587    {
588        status.code().unwrap_or(1)
589    }
590}
591
592#[cfg(all(test, unix))]
593mod grace_tests {
594    use super::*;
595
596    #[test]
597    fn the_kill_grace_period_is_read_as_nx_reads_it() {
598        assert_eq!(grace_period(None), Duration::from_millis(5000));
599        assert_eq!(grace_period(Some("250")), Duration::from_millis(250));
600        assert_eq!(grace_period(Some("soon")), Duration::from_millis(5000));
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn empties_scratch_and_makes_a_missing_directory() {
610        let temp = tempfile::tempdir().unwrap();
611        let kept = temp.path().join("cache");
612        std::fs::create_dir_all(kept.join("nested")).unwrap();
613        std::fs::write(kept.join("nested/entry"), "old").unwrap();
614        std::fs::write(kept.join("entry"), "old").unwrap();
615        let missing = temp.path().join("missing");
616        let scratch = |directory: &std::path::Path| crate::Scratch {
617            root: temp.path().to_owned(),
618            directory: directory.to_owned(),
619        };
620        empty_scratch(&[scratch(&kept), scratch(&missing)]).unwrap();
621        assert_eq!(std::fs::read_dir(&kept).unwrap().count(), 0);
622        assert!(missing.is_dir());
623    }
624
625    #[cfg(unix)]
626    #[test]
627    fn leaves_scratch_behind_a_link_made_since_the_run_was_prepared() {
628        let temp = tempfile::tempdir().unwrap();
629        let outside = tempfile::tempdir().unwrap();
630        std::fs::create_dir(outside.path().join("cache")).unwrap();
631        std::fs::write(outside.path().join("cache/kept"), "kept").unwrap();
632        std::os::unix::fs::symlink(outside.path(), temp.path().join("build")).unwrap();
633        let scratch = crate::Scratch {
634            root: temp.path().to_owned(),
635            directory: temp.path().join("build/cache"),
636        };
637        assert!(empty_scratch(&[scratch]).is_err());
638        assert!(outside.path().join("cache/kept").exists());
639    }
640
641    #[test]
642    fn leaves_scratch_that_is_no_path_inside_the_workspace() {
643        let temp = tempfile::tempdir().unwrap();
644        let workspace = temp.path().join("workspace");
645        std::fs::create_dir_all(temp.path().join("outside")).unwrap();
646        std::fs::write(temp.path().join("outside/kept"), "kept").unwrap();
647        for directory in [workspace.join("../outside"), temp.path().join("outside")] {
648            let scratch = crate::Scratch {
649                root: workspace.clone(),
650                directory,
651            };
652            assert!(empty_scratch(&[scratch]).is_err());
653        }
654        assert!(temp.path().join("outside/kept").exists());
655    }
656}