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 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 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 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 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 && 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 unsafe {
520 command.pre_exec(|| {
521 terminal::claim();
522 Ok(())
523 });
524 }
525 }
526 Ok((Running::Group(command.group_spawn()?), None))
527}
528
529pub(crate) mod terminal {
531 #[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 #[cfg(unix)]
545 pub fn claim() {
546 unsafe { foreground(libc::getpgrp()) }
547 }
548
549 #[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 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}