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
19enum 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
78struct 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 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#[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 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 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 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 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 #[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 #[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 let _ = child.kill();
324 if !status.success() {
325 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 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 = 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
395pub 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
416fn spawn(
420 task: &PreparedTask,
421 text: &str,
422 index: usize,
423 capture: bool,
424 interactive: bool,
425) -> Result<(Running, Option<std::fs::File>)> {
426 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 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 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 && 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 unsafe {
513 command.pre_exec(|| {
514 terminal::claim();
515 Ok(())
516 });
517 }
518 }
519 Ok((Running::Group(command.group_spawn()?), None))
520}
521
522pub(crate) mod terminal {
524 #[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 #[cfg(unix)]
538 pub fn claim() {
539 unsafe { foreground(libc::getpgrp()) }
540 }
541
542 #[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 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}