Skip to main content

qk_executor/
process.rs

1use std::process::{Command, Stdio};
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::time::Duration;
4
5use anyhow::{Context, Result};
6use command_group::{CommandGroup, GroupChild};
7
8use crate::{Capture, Decoration, Display, PreparedTask, Shown};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Outcome {
12    Success,
13    Failed(i32),
14    Cancelled,
15}
16
17struct Children(Vec<GroupChild>);
18
19impl Drop for Children {
20    fn drop(&mut self) {
21        // Also clean up when spawning or polling one command fails.
22        for child in &mut self.0 {
23            let _ = child.kill();
24        }
25        for child in &mut self.0 {
26            let _ = child.wait();
27        }
28    }
29}
30
31/// How long a cancelled or stopped command may take to exit after SIGTERM.
32#[cfg(unix)]
33const GRACE: Duration = Duration::from_secs(5);
34
35impl Children {
36    /// Asks every process group to exit, then leaves stragglers to `Drop`.
37    fn terminate(&mut self) {
38        #[cfg(unix)]
39        {
40            use command_group::{Signal, UnixChildExt};
41            for child in &self.0 {
42                let _ = child.signal(Signal::SIGTERM);
43            }
44            let deadline = std::time::Instant::now() + GRACE;
45            while std::time::Instant::now() < deadline
46                && self
47                    .0
48                    .iter_mut()
49                    .any(|child| matches!(child.try_wait(), Ok(None)))
50            {
51                std::thread::sleep(Duration::from_millis(20));
52            }
53        }
54    }
55}
56
57pub fn execute(task: &PreparedTask, cancelled: &AtomicBool) -> Result<Outcome> {
58    if task.display == Display::Stream
59        && task.ready_when.is_empty()
60        && task.decorations.iter().all(Decoration::is_empty)
61    {
62        // Straight through: the task writes to the terminal itself.
63        return execute_captured(task, cancelled, None);
64    }
65    let capture =
66        Capture::new(None, task.display.clone()).ready_when(&task.ready_when, task.ready.clone());
67    execute_captured(task, cancelled, Some(&capture))
68}
69
70pub fn execute_captured(
71    task: &PreparedTask,
72    cancelled: &AtomicBool,
73    capture: Option<&Capture>,
74) -> Result<Outcome> {
75    // Takes the terminal back from an interactive command, however it ends.
76    let _terminal = (task.interactive && task.commands.len() == 1)
77        .then(terminal::Foreground::take_back_on_drop);
78    std::thread::scope(|scope| {
79        let mut readers = Vec::new();
80        let outcome = (|| {
81            let mut pending = task.commands.iter().enumerate();
82            let mut children = Children(Vec::new());
83            loop {
84                if cancelled.load(Ordering::SeqCst) {
85                    children.terminate();
86                    return Ok(Outcome::Cancelled);
87                }
88                if children.0.is_empty() || task.parallel {
89                    for (index, command) in pending.by_ref() {
90                        let interactive = task.interactive && task.commands.len() == 1;
91                        let mut child = spawn(task, command, index, capture.is_some(), interactive)
92                            .with_context(|| format!("cannot start command for {}", task.id))?;
93                        if let Some(capture) = capture {
94                            let stdout =
95                                child.inner().stdout.take().context("missing stdout pipe")?;
96                            let stderr =
97                                child.inner().stderr.take().context("missing stderr pipe")?;
98                            let decoration = task.decorations.get(index);
99                            readers
100                                .push(scope.spawn(move || capture.copy(stdout, false, decoration)));
101                            readers
102                                .push(scope.spawn(move || capture.copy(stderr, true, decoration)));
103                        }
104                        children.0.push(child);
105                        if !task.parallel {
106                            break;
107                        }
108                    }
109                }
110                if children.0.is_empty() {
111                    return Ok(Outcome::Success);
112                }
113                let mut index = 0;
114                while index < children.0.len() {
115                    match children.0[index]
116                        .try_wait()
117                        .context("cannot wait for command")?
118                    {
119                        Some(status) => {
120                            let mut child = children.0.swap_remove(index);
121                            // Finite tasks may not leave background descendants running.
122                            let _ = child.kill();
123                            if !status.success() {
124                                // As in Nx, commands run side by side fail the
125                                // task with 1, one after another with the code.
126                                let code = if task.parallel
127                                    && task.commands.len() > 1
128                                    && task.ready_when.is_empty()
129                                {
130                                    1
131                                } else {
132                                    exit_code(status)
133                                };
134                                return Ok(Outcome::Failed(code));
135                            }
136                        }
137                        None => index += 1,
138                    }
139                }
140                std::thread::sleep(Duration::from_millis(20));
141            }
142        })();
143        for reader in readers {
144            reader
145                .join()
146                .map_err(|_| anyhow::anyhow!("output capture thread panicked"))??;
147        }
148        if let (Some(capture), Ok(outcome)) = (capture, &outcome) {
149            capture.finish(if *outcome == Outcome::Success {
150                Shown::Success
151            } else {
152                Shown::Failure
153            })?;
154        }
155        outcome
156    })
157}
158
159/// A command running `text` in the shell tasks run in: `/bin/sh` on Unix,
160/// `cmd.exe` on Windows.
161pub fn shell(text: &str) -> Command {
162    #[cfg(windows)]
163    {
164        use std::os::windows::process::CommandExt;
165        let mut command =
166            Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()));
167        command
168            .args(["/D", "/S", "/C"])
169            .raw_arg(format!("\"{text}\""));
170        command
171    }
172    #[cfg(not(windows))]
173    {
174        let mut command = Command::new("/bin/sh");
175        command.arg("-c").arg(text);
176        command
177    }
178}
179
180/// Launch a task command with its environment, confinement and output streams.
181fn spawn(
182    task: &PreparedTask,
183    text: &str,
184    index: usize,
185    capture: bool,
186    interactive: bool,
187) -> Result<GroupChild> {
188    #[cfg(windows)]
189    let mut command = shell(text);
190    #[cfg(not(windows))]
191    let mut command = match &task.sandbox {
192        Some(crate::Confinement::Seatbelt(profile)) => {
193            let mut command = Command::new("sandbox-exec");
194            command.arg("-f").arg(profile).args(["/bin/sh", "-c", text]);
195            command
196        }
197        _ => shell(text),
198    };
199    if let Some(recording) = &task.recording {
200        command = recording.command(index, text);
201    }
202    #[cfg(target_os = "linux")]
203    if let Some(crate::Confinement::Landlock(ruleset)) = task.sandbox {
204        use std::os::unix::process::CommandExt;
205        // Between fork and exec: only system calls.
206        unsafe {
207            command.pre_exec(move || {
208                if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0
209                    || libc::syscall(libc::SYS_landlock_restrict_self, ruleset, 0u32) != 0
210                {
211                    return Err(std::io::Error::last_os_error());
212                }
213                Ok(())
214            });
215        }
216    }
217    command
218        .current_dir(&task.cwd)
219        .env_clear()
220        .envs(&task.env)
221        .envs(&task.execution)
222        .stdin(if interactive {
223            Stdio::inherit()
224        } else {
225            Stdio::null()
226        })
227        .stdout(if capture {
228            Stdio::piped()
229        } else {
230            Stdio::inherit()
231        })
232        .stderr(if capture {
233            Stdio::piped()
234        } else {
235            Stdio::inherit()
236        });
237    // Re-entered exec inherits confinement. Override any caller-supplied
238    // marker so an unsandboxed task cannot accidentally claim confinement.
239    command.env_remove("QK_TASK_SANDBOX");
240    if task.sandbox.is_some() {
241        command.env("QK_TASK_SANDBOX", "1");
242    }
243    #[cfg(unix)]
244    if interactive && terminal::in_foreground() {
245        use std::os::unix::process::CommandExt;
246        // Each command runs in its own process group, which the terminal
247        // stops when it reads; so the command makes its group the terminal's
248        // foreground before it starts, as a shell does for a job.
249        unsafe {
250            command.pre_exec(|| {
251                terminal::claim();
252                Ok(())
253            });
254        }
255    }
256    Ok(command.group_spawn()?)
257}
258
259/// Handing the terminal to an interactive command and back.
260mod terminal {
261    /// Whether stdin is a terminal whose foreground is qk's process group.
262    #[cfg_attr(not(unix), allow(dead_code))]
263    pub fn in_foreground() -> bool {
264        #[cfg(unix)]
265        unsafe {
266            libc::isatty(0) == 1 && libc::tcgetpgrp(0) == libc::getpgrp()
267        }
268        #[cfg(not(unix))]
269        false
270    }
271
272    /// Makes the calling process's group the terminal's foreground. Run in
273    /// the child between fork and exec, so only async-signal-safe calls.
274    #[cfg(unix)]
275    pub fn claim() {
276        unsafe { foreground(libc::getpgrp()) }
277    }
278
279    /// `tcsetpgrp` from a background group raises SIGTTOU, which would stop
280    /// the caller; it is ignored around the call.
281    #[cfg(unix)]
282    unsafe fn foreground(group: libc::pid_t) {
283        unsafe {
284            let previous = libc::signal(libc::SIGTTOU, libc::SIG_IGN);
285            libc::tcsetpgrp(0, group);
286            libc::signal(libc::SIGTTOU, previous);
287        }
288    }
289
290    /// Returns the terminal to qk's group when dropped, if qk had it.
291    pub struct Foreground {
292        #[cfg(unix)]
293        group: Option<libc::pid_t>,
294    }
295
296    impl Foreground {
297        pub fn take_back_on_drop() -> Self {
298            Self {
299                #[cfg(unix)]
300                group: in_foreground().then(|| unsafe { libc::getpgrp() }),
301            }
302        }
303    }
304
305    impl Drop for Foreground {
306        fn drop(&mut self) {
307            #[cfg(unix)]
308            if let Some(group) = self.group {
309                unsafe { foreground(group) }
310            }
311        }
312    }
313}
314
315fn exit_code(status: std::process::ExitStatus) -> i32 {
316    #[cfg(unix)]
317    {
318        use std::os::unix::process::ExitStatusExt;
319        status
320            .code()
321            .unwrap_or_else(|| 128 + status.signal().unwrap_or(1))
322    }
323    #[cfg(not(unix))]
324    {
325        status.code().unwrap_or(1)
326    }
327}