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    if let Some(crate::Confinement::Refused(reason)) = &task.sandbox {
433        anyhow::bail!(
434            "--sandbox=enforce cannot sandbox it: {reason}; set \"qk:sandbox\": false on its target to run it unsandboxed"
435        );
436    }
437    #[cfg(windows)]
438    let mut command = shell(text);
439    #[cfg(not(windows))]
440    let mut command = match &task.sandbox {
441        Some(crate::Confinement::Seatbelt(profile)) => {
442            // By its path, which macOS fixes: the task's own PATH, which
443            // its environment can build, need not hold it.
444            let mut command = Command::new("/usr/bin/sandbox-exec");
445            command.arg("-f").arg(profile).args(["/bin/sh", "-c", text]);
446            command
447        }
448        _ => shell(text),
449    };
450    if let Some(recording) = &task.recording {
451        command = recording.command(index, text, &task.cwd);
452    }
453    #[cfg(target_os = "linux")]
454    if let Some(crate::Confinement::Landlock(ruleset)) = task.sandbox {
455        use std::os::unix::process::CommandExt;
456        // Between fork and exec: only system calls.
457        unsafe {
458            command.pre_exec(move || {
459                if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0
460                    || libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0u32) != 0
461                {
462                    return Err(std::io::Error::last_os_error());
463                }
464                Ok(())
465            });
466        }
467    }
468    command
469        .current_dir(&task.cwd)
470        .env_clear()
471        .envs(&task.env)
472        .envs(&task.execution)
473        .stdin(if interactive {
474            Stdio::inherit()
475        } else {
476            Stdio::null()
477        })
478        .stdout(if capture {
479            Stdio::piped()
480        } else {
481            Stdio::inherit()
482        })
483        .stderr(if capture {
484            Stdio::piped()
485        } else {
486            Stdio::inherit()
487        });
488    if let Some(node) = &task.node_path
489        && let Some((key, path)) = node.path(&task.env)?
490    {
491        command.env(key, path);
492    }
493    // Re-entered exec inherits confinement. Override any caller-supplied
494    // marker so an unsandboxed task cannot accidentally claim confinement.
495    command.env_remove("QK_TASK_SANDBOX");
496    if task.sandbox.is_some() {
497        command.env("QK_TASK_SANDBOX", "1");
498    }
499    #[cfg(unix)]
500    if capture
501        && task.pseudo_terminal
502        // As in Nx, prefixed lines are not drawn on a terminal.
503        && task
504            .decorations
505            .get(index)
506            .is_none_or(|decoration| decoration.prefix.is_none())
507        && std::io::IsTerminal::is_terminal(&std::io::stdout())
508    {
509        let input = interactive && crate::pty::forwards_input();
510        let (session, leader) = crate::pty::spawn(&mut command, input)?;
511        return Ok((Running::Session(session), Some(leader)));
512    }
513    #[cfg(unix)]
514    if interactive && terminal::in_foreground() {
515        use std::os::unix::process::CommandExt;
516        // Each command runs in its own process group, which the terminal
517        // stops when it reads; so the command makes its group the terminal's
518        // foreground before it starts, as a shell does for a job.
519        unsafe {
520            command.pre_exec(|| {
521                terminal::claim();
522                Ok(())
523            });
524        }
525    }
526    Ok((Running::Group(command.group_spawn()?), None))
527}
528
529/// Handing the terminal to an interactive command and back.
530pub(crate) mod terminal {
531    /// Whether stdin is a terminal whose foreground is qk's process group.
532    #[cfg_attr(not(unix), allow(dead_code))]
533    pub fn in_foreground() -> bool {
534        #[cfg(unix)]
535        unsafe {
536            libc::isatty(0) == 1 && libc::tcgetpgrp(0) == libc::getpgrp()
537        }
538        #[cfg(not(unix))]
539        false
540    }
541
542    /// Makes the calling process's group the terminal's foreground. Run in
543    /// the child between fork and exec, so only async-signal-safe calls.
544    #[cfg(unix)]
545    pub fn claim() {
546        unsafe { foreground(libc::getpgrp()) }
547    }
548
549    /// `tcsetpgrp` from a background group raises SIGTTOU, which would stop
550    /// the caller; it is ignored around the call.
551    #[cfg(unix)]
552    unsafe fn foreground(group: libc::pid_t) {
553        unsafe {
554            let previous = libc::signal(libc::SIGTTOU, libc::SIG_IGN);
555            libc::tcsetpgrp(0, group);
556            libc::signal(libc::SIGTTOU, previous);
557        }
558    }
559
560    /// Returns the terminal to qk's group when dropped, if qk had it.
561    pub struct Foreground {
562        #[cfg(unix)]
563        group: Option<libc::pid_t>,
564    }
565
566    impl Foreground {
567        pub fn take_back_on_drop() -> Self {
568            Self {
569                #[cfg(unix)]
570                group: in_foreground().then(|| unsafe { libc::getpgrp() }),
571            }
572        }
573    }
574
575    impl Drop for Foreground {
576        fn drop(&mut self) {
577            #[cfg(unix)]
578            if let Some(group) = self.group {
579                unsafe { foreground(group) }
580            }
581        }
582    }
583}
584
585fn exit_code(status: std::process::ExitStatus) -> i32 {
586    #[cfg(unix)]
587    {
588        use std::os::unix::process::ExitStatusExt;
589        status
590            .code()
591            .unwrap_or_else(|| 128 + status.signal().unwrap_or(1))
592    }
593    #[cfg(not(unix))]
594    {
595        status.code().unwrap_or(1)
596    }
597}
598
599#[cfg(all(test, unix))]
600mod grace_tests {
601    use super::*;
602
603    #[test]
604    fn the_kill_grace_period_is_read_as_nx_reads_it() {
605        assert_eq!(grace_period(None), Duration::from_millis(5000));
606        assert_eq!(grace_period(Some("250")), Duration::from_millis(250));
607        assert_eq!(grace_period(Some("soon")), Duration::from_millis(5000));
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn empties_scratch_and_makes_a_missing_directory() {
617        let temp = tempfile::tempdir().unwrap();
618        let kept = temp.path().join("cache");
619        std::fs::create_dir_all(kept.join("nested")).unwrap();
620        std::fs::write(kept.join("nested/entry"), "old").unwrap();
621        std::fs::write(kept.join("entry"), "old").unwrap();
622        let missing = temp.path().join("missing");
623        let scratch = |directory: &std::path::Path| crate::Scratch {
624            root: temp.path().to_owned(),
625            directory: directory.to_owned(),
626        };
627        empty_scratch(&[scratch(&kept), scratch(&missing)]).unwrap();
628        assert_eq!(std::fs::read_dir(&kept).unwrap().count(), 0);
629        assert!(missing.is_dir());
630    }
631
632    #[cfg(unix)]
633    #[test]
634    fn leaves_scratch_behind_a_link_made_since_the_run_was_prepared() {
635        let temp = tempfile::tempdir().unwrap();
636        let outside = tempfile::tempdir().unwrap();
637        std::fs::create_dir(outside.path().join("cache")).unwrap();
638        std::fs::write(outside.path().join("cache/kept"), "kept").unwrap();
639        std::os::unix::fs::symlink(outside.path(), temp.path().join("build")).unwrap();
640        let scratch = crate::Scratch {
641            root: temp.path().to_owned(),
642            directory: temp.path().join("build/cache"),
643        };
644        assert!(empty_scratch(&[scratch]).is_err());
645        assert!(outside.path().join("cache/kept").exists());
646    }
647
648    #[test]
649    fn leaves_scratch_that_is_no_path_inside_the_workspace() {
650        let temp = tempfile::tempdir().unwrap();
651        let workspace = temp.path().join("workspace");
652        std::fs::create_dir_all(temp.path().join("outside")).unwrap();
653        std::fs::write(temp.path().join("outside/kept"), "kept").unwrap();
654        for directory in [workspace.join("../outside"), temp.path().join("outside")] {
655            let scratch = crate::Scratch {
656                root: workspace.clone(),
657                directory,
658            };
659            assert!(empty_scratch(&[scratch]).is_err());
660        }
661        assert!(temp.path().join("outside/kept").exists());
662    }
663}