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        let outcome = outcome.and_then(|outcome| settled(task, capture, outcome));
359        // As in Nx, commands that all exit without printing their readyWhen
360        // text fail the task: it never became ready, and nothing waiting on
361        // it would have started.
362        let outcome = match (capture, outcome) {
363            (Some(capture), Ok(Outcome::Success)) => {
364                let missing = capture.unready();
365                if missing.is_empty() {
366                    Ok(Outcome::Success)
367                } else {
368                    let exited = match task.commands.as_slice() {
369                        [command] => format!("command \"{command}\""),
370                        _ => "all commands".to_owned(),
371                    };
372                    let missing = missing
373                        .iter()
374                        .map(|text| format!("\"{text}\""))
375                        .collect::<Vec<_>>()
376                        .join(", ");
377                    capture.note(&format!(
378                        "Warning: {exited} exited before printing the readyWhen text {missing}"
379                    ))?;
380                    Ok(Outcome::Failed(1))
381                }
382            }
383            (_, outcome) => outcome,
384        };
385        if let (Some(capture), Ok(outcome)) = (capture, &outcome) {
386            capture.finish(if *outcome == Outcome::Success {
387                Shown::Success
388            } else {
389                Shown::Failure
390            })?;
391        }
392        outcome
393    })
394}
395
396/// Over an overlay, applies the commands' writes to the workspace as they
397/// end: before the outcome is shown and before the cache stores the task's
398/// outputs from it. What could not be applied is noted in the task's output,
399/// and a task that succeeded fails, as its outputs may be missing; one that
400/// failed, or that was stopped, as a continuous task is, keeps its outcome.
401fn settled(task: &PreparedTask, capture: Option<&Capture>, outcome: Outcome) -> Result<Outcome> {
402    let Some(crate::Confinement::Overlay(overlay)) = &task.sandbox else {
403        return Ok(outcome);
404    };
405    let failed = &overlay.settle().failed;
406    if failed.is_empty() {
407        return Ok(outcome);
408    }
409    let message = format!(
410        "cannot apply what its commands wrote to the workspace: {}",
411        failed.join("; ")
412    );
413    match capture {
414        Some(capture) => capture.note(&format!("Error: {message}"))?,
415        None => crate::status!("qk: {message}"),
416    }
417    Ok(match outcome {
418        Outcome::Success => Outcome::Failed(1),
419        outcome => outcome,
420    })
421}
422
423/// A command running `text` in the shell tasks run in: `/bin/sh` on Unix,
424/// `cmd.exe` on Windows.
425pub fn shell(text: &str) -> Command {
426    #[cfg(windows)]
427    {
428        use std::os::windows::process::CommandExt;
429        let mut command =
430            Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()));
431        command
432            .args(["/D", "/S", "/C"])
433            .raw_arg(format!("\"{text}\""));
434        command
435    }
436    #[cfg(not(windows))]
437    {
438        let mut command = Command::new("/bin/sh");
439        command.arg("-c").arg(text);
440        command
441    }
442}
443
444/// Launch a task command with its environment, confinement and output streams.
445/// A captured command runs under a pseudo-terminal when qk's output is a
446/// terminal, as in Nx; the terminal's leader end is returned with it.
447fn spawn(
448    task: &PreparedTask,
449    text: &str,
450    index: usize,
451    capture: bool,
452    interactive: bool,
453) -> Result<(Running, Option<std::fs::File>)> {
454    // Checked here rather than when the task is prepared: a dependency may
455    // create the directory, as a native prebuild creates the project a build
456    // runs in.
457    if !task.cwd.is_dir() {
458        anyhow::bail!("working directory does not exist: {}", task.cwd.display());
459    }
460    if let Some(crate::Confinement::Refused(reason)) = &task.sandbox {
461        anyhow::bail!(
462            "--sandbox=enforce cannot sandbox it: {reason}; set \"qk:sandbox\": false on its target to run it unsandboxed"
463        );
464    }
465    #[cfg(windows)]
466    let mut command = shell(text);
467    #[cfg(not(windows))]
468    let mut command = match &task.sandbox {
469        Some(crate::Confinement::Seatbelt(profile)) => {
470            // By its path, which macOS fixes: the task's own PATH, which
471            // its environment can build, need not hold it.
472            let mut command = Command::new("/usr/bin/sandbox-exec");
473            command.arg("-f").arg(profile).args(["/bin/sh", "-c", text]);
474            command
475        }
476        _ => shell(text),
477    };
478    if let Some(recording) = &task.recording {
479        command = recording.command(index, text, &task.cwd);
480    }
481    #[cfg(target_os = "linux")]
482    if let Some(crate::Confinement::Overlay(overlay)) = &task.sandbox {
483        use std::os::unix::process::CommandExt;
484        // Commands run one after another share an upper layer, so each sees
485        // what those before it wrote; ones run side by side each have their
486        // own, as two overlays cannot share one.
487        let slot = if task.parallel { index } else { 0 };
488        let setup = crate::overlay::Setup::new(overlay, slot, &task.cwd)?;
489        unsafe {
490            command.pre_exec(move || setup.enter());
491        }
492    }
493    #[cfg(target_os = "linux")]
494    if let Some(crate::Confinement::Landlock(ruleset)) = task.sandbox {
495        use std::os::unix::process::CommandExt;
496        // Between fork and exec: only system calls.
497        unsafe {
498            command.pre_exec(move || {
499                if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0
500                    || libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0u32) != 0
501                {
502                    return Err(std::io::Error::last_os_error());
503                }
504                Ok(())
505            });
506        }
507    }
508    command
509        .current_dir(&task.cwd)
510        .env_clear()
511        .envs(&task.env)
512        .envs(&task.execution)
513        .stdin(if interactive {
514            Stdio::inherit()
515        } else {
516            Stdio::null()
517        })
518        .stdout(if capture {
519            Stdio::piped()
520        } else {
521            Stdio::inherit()
522        })
523        .stderr(if capture {
524            Stdio::piped()
525        } else {
526            Stdio::inherit()
527        });
528    if let Some(node) = &task.node_path
529        && let Some((key, path)) = node.path(&task.env)?
530    {
531        command.env(key, path);
532    }
533    // Re-entered exec inherits confinement. Override any caller-supplied
534    // marker so an unsandboxed task cannot accidentally claim confinement.
535    command.env_remove("QK_TASK_SANDBOX");
536    if task.sandbox.is_some() {
537        command.env("QK_TASK_SANDBOX", "1");
538    }
539    #[cfg(unix)]
540    if capture
541        && task.pseudo_terminal
542        // As in Nx, prefixed lines are not drawn on a terminal.
543        && task
544            .decorations
545            .get(index)
546            .is_none_or(|decoration| decoration.prefix.is_none())
547        && std::io::IsTerminal::is_terminal(&std::io::stdout())
548    {
549        let input = interactive && crate::pty::forwards_input();
550        let (session, leader) = crate::pty::spawn(&mut command, input)?;
551        return Ok((Running::Session(session), Some(leader)));
552    }
553    #[cfg(unix)]
554    if interactive && terminal::in_foreground() {
555        use std::os::unix::process::CommandExt;
556        // Each command runs in its own process group, which the terminal
557        // stops when it reads; so the command makes its group the terminal's
558        // foreground before it starts, as a shell does for a job.
559        unsafe {
560            command.pre_exec(|| {
561                terminal::claim();
562                Ok(())
563            });
564        }
565    }
566    Ok((Running::Group(command.group_spawn()?), None))
567}
568
569/// Handing the terminal to an interactive command and back.
570pub(crate) mod terminal {
571    /// Whether stdin is a terminal whose foreground is qk's process group.
572    #[cfg_attr(not(unix), allow(dead_code))]
573    pub fn in_foreground() -> bool {
574        #[cfg(unix)]
575        unsafe {
576            libc::isatty(0) == 1 && libc::tcgetpgrp(0) == libc::getpgrp()
577        }
578        #[cfg(not(unix))]
579        false
580    }
581
582    /// Makes the calling process's group the terminal's foreground. Run in
583    /// the child between fork and exec, so only async-signal-safe calls.
584    #[cfg(unix)]
585    pub fn claim() {
586        unsafe { foreground(libc::getpgrp()) }
587    }
588
589    /// `tcsetpgrp` from a background group raises SIGTTOU, which would stop
590    /// the caller; it is ignored around the call.
591    #[cfg(unix)]
592    unsafe fn foreground(group: libc::pid_t) {
593        unsafe {
594            let previous = libc::signal(libc::SIGTTOU, libc::SIG_IGN);
595            libc::tcsetpgrp(0, group);
596            libc::signal(libc::SIGTTOU, previous);
597        }
598    }
599
600    /// Returns the terminal to qk's group when dropped, if qk had it.
601    pub struct Foreground {
602        #[cfg(unix)]
603        group: Option<libc::pid_t>,
604    }
605
606    impl Foreground {
607        pub fn take_back_on_drop() -> Self {
608            Self {
609                #[cfg(unix)]
610                group: in_foreground().then(|| unsafe { libc::getpgrp() }),
611            }
612        }
613    }
614
615    impl Drop for Foreground {
616        fn drop(&mut self) {
617            #[cfg(unix)]
618            if let Some(group) = self.group {
619                unsafe { foreground(group) }
620            }
621        }
622    }
623}
624
625fn exit_code(status: std::process::ExitStatus) -> i32 {
626    #[cfg(unix)]
627    {
628        use std::os::unix::process::ExitStatusExt;
629        status
630            .code()
631            .unwrap_or_else(|| 128 + status.signal().unwrap_or(1))
632    }
633    #[cfg(not(unix))]
634    {
635        status.code().unwrap_or(1)
636    }
637}
638
639#[cfg(all(test, unix))]
640mod grace_tests {
641    use super::*;
642
643    #[test]
644    fn the_kill_grace_period_is_read_as_nx_reads_it() {
645        assert_eq!(grace_period(None), Duration::from_millis(5000));
646        assert_eq!(grace_period(Some("250")), Duration::from_millis(250));
647        assert_eq!(grace_period(Some("soon")), Duration::from_millis(5000));
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    #[test]
656    fn empties_scratch_and_makes_a_missing_directory() {
657        let temp = tempfile::tempdir().unwrap();
658        let kept = temp.path().join("cache");
659        std::fs::create_dir_all(kept.join("nested")).unwrap();
660        std::fs::write(kept.join("nested/entry"), "old").unwrap();
661        std::fs::write(kept.join("entry"), "old").unwrap();
662        let missing = temp.path().join("missing");
663        let scratch = |directory: &std::path::Path| crate::Scratch {
664            root: temp.path().to_owned(),
665            directory: directory.to_owned(),
666        };
667        empty_scratch(&[scratch(&kept), scratch(&missing)]).unwrap();
668        assert_eq!(std::fs::read_dir(&kept).unwrap().count(), 0);
669        assert!(missing.is_dir());
670    }
671
672    #[cfg(unix)]
673    #[test]
674    fn leaves_scratch_behind_a_link_made_since_the_run_was_prepared() {
675        let temp = tempfile::tempdir().unwrap();
676        let outside = tempfile::tempdir().unwrap();
677        std::fs::create_dir(outside.path().join("cache")).unwrap();
678        std::fs::write(outside.path().join("cache/kept"), "kept").unwrap();
679        std::os::unix::fs::symlink(outside.path(), temp.path().join("build")).unwrap();
680        let scratch = crate::Scratch {
681            root: temp.path().to_owned(),
682            directory: temp.path().join("build/cache"),
683        };
684        assert!(empty_scratch(&[scratch]).is_err());
685        assert!(outside.path().join("cache/kept").exists());
686    }
687
688    #[test]
689    fn leaves_scratch_that_is_no_path_inside_the_workspace() {
690        let temp = tempfile::tempdir().unwrap();
691        let workspace = temp.path().join("workspace");
692        std::fs::create_dir_all(temp.path().join("outside")).unwrap();
693        std::fs::write(temp.path().join("outside/kept"), "kept").unwrap();
694        for directory in [workspace.join("../outside"), temp.path().join("outside")] {
695            let scratch = crate::Scratch {
696                root: workspace.clone(),
697                directory,
698            };
699            assert!(empty_scratch(&[scratch]).is_err());
700        }
701        assert!(temp.path().join("outside/kept").exists());
702    }
703}