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 = outcome.and_then(|outcome| settled(task, capture, outcome));
359 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
396fn 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
423pub 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
444fn spawn(
448 task: &PreparedTask,
449 text: &str,
450 index: usize,
451 capture: bool,
452 interactive: bool,
453) -> Result<(Running, Option<std::fs::File>)> {
454 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 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 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 unsafe {
498 command.pre_exec(move || crate::landlock::restrict_self(ruleset));
499 }
500 }
501 command
502 .current_dir(&task.cwd)
503 .env_clear()
504 .envs(&task.env)
505 .envs(&task.execution)
506 .stdin(if interactive {
507 Stdio::inherit()
508 } else {
509 Stdio::null()
510 })
511 .stdout(if capture {
512 Stdio::piped()
513 } else {
514 Stdio::inherit()
515 })
516 .stderr(if capture {
517 Stdio::piped()
518 } else {
519 Stdio::inherit()
520 });
521 if let Some(node) = &task.node_path
522 && let Some((key, path)) = node.path(&task.env)?
523 {
524 command.env(key, path);
525 }
526 command.env_remove("QK_TASK_SANDBOX");
529 if task.sandbox.is_some() {
530 command.env("QK_TASK_SANDBOX", "1");
531 }
532 #[cfg(unix)]
533 if capture
534 && task.pseudo_terminal
535 && task
537 .decorations
538 .get(index)
539 .is_none_or(|decoration| decoration.prefix.is_none())
540 && std::io::IsTerminal::is_terminal(&std::io::stdout())
541 {
542 let input = interactive && crate::pty::forwards_input();
543 let (session, leader) = crate::pty::spawn(&mut command, input)?;
544 return Ok((Running::Session(session), Some(leader)));
545 }
546 #[cfg(unix)]
547 if interactive && terminal::in_foreground() {
548 use std::os::unix::process::CommandExt;
549 unsafe {
553 command.pre_exec(|| {
554 terminal::claim();
555 Ok(())
556 });
557 }
558 }
559 Ok((Running::Group(command.group_spawn()?), None))
560}
561
562pub(crate) mod terminal {
564 #[cfg_attr(not(unix), allow(dead_code))]
566 pub fn in_foreground() -> bool {
567 #[cfg(unix)]
568 unsafe {
569 libc::isatty(0) == 1 && libc::tcgetpgrp(0) == libc::getpgrp()
570 }
571 #[cfg(not(unix))]
572 false
573 }
574
575 #[cfg(unix)]
578 pub fn claim() {
579 unsafe { foreground(libc::getpgrp()) }
580 }
581
582 #[cfg(unix)]
585 unsafe fn foreground(group: libc::pid_t) {
586 unsafe {
587 let previous = libc::signal(libc::SIGTTOU, libc::SIG_IGN);
588 libc::tcsetpgrp(0, group);
589 libc::signal(libc::SIGTTOU, previous);
590 }
591 }
592
593 pub struct Foreground {
595 #[cfg(unix)]
596 group: Option<libc::pid_t>,
597 }
598
599 impl Foreground {
600 pub fn take_back_on_drop() -> Self {
601 Self {
602 #[cfg(unix)]
603 group: in_foreground().then(|| unsafe { libc::getpgrp() }),
604 }
605 }
606 }
607
608 impl Drop for Foreground {
609 fn drop(&mut self) {
610 #[cfg(unix)]
611 if let Some(group) = self.group {
612 unsafe { foreground(group) }
613 }
614 }
615 }
616}
617
618fn exit_code(status: std::process::ExitStatus) -> i32 {
619 #[cfg(unix)]
620 {
621 use std::os::unix::process::ExitStatusExt;
622 status
623 .code()
624 .unwrap_or_else(|| 128 + status.signal().unwrap_or(1))
625 }
626 #[cfg(not(unix))]
627 {
628 status.code().unwrap_or(1)
629 }
630}
631
632#[cfg(all(test, unix))]
633mod grace_tests {
634 use super::*;
635
636 #[test]
637 fn the_kill_grace_period_is_read_as_nx_reads_it() {
638 assert_eq!(grace_period(None), Duration::from_millis(5000));
639 assert_eq!(grace_period(Some("250")), Duration::from_millis(250));
640 assert_eq!(grace_period(Some("soon")), Duration::from_millis(5000));
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 #[test]
649 fn empties_scratch_and_makes_a_missing_directory() {
650 let temp = tempfile::tempdir().unwrap();
651 let kept = temp.path().join("cache");
652 std::fs::create_dir_all(kept.join("nested")).unwrap();
653 std::fs::write(kept.join("nested/entry"), "old").unwrap();
654 std::fs::write(kept.join("entry"), "old").unwrap();
655 let missing = temp.path().join("missing");
656 let scratch = |directory: &std::path::Path| crate::Scratch {
657 root: temp.path().to_owned(),
658 directory: directory.to_owned(),
659 };
660 empty_scratch(&[scratch(&kept), scratch(&missing)]).unwrap();
661 assert_eq!(std::fs::read_dir(&kept).unwrap().count(), 0);
662 assert!(missing.is_dir());
663 }
664
665 #[cfg(unix)]
666 #[test]
667 fn leaves_scratch_behind_a_link_made_since_the_run_was_prepared() {
668 let temp = tempfile::tempdir().unwrap();
669 let outside = tempfile::tempdir().unwrap();
670 std::fs::create_dir(outside.path().join("cache")).unwrap();
671 std::fs::write(outside.path().join("cache/kept"), "kept").unwrap();
672 std::os::unix::fs::symlink(outside.path(), temp.path().join("build")).unwrap();
673 let scratch = crate::Scratch {
674 root: temp.path().to_owned(),
675 directory: temp.path().join("build/cache"),
676 };
677 assert!(empty_scratch(&[scratch]).is_err());
678 assert!(outside.path().join("cache/kept").exists());
679 }
680
681 #[test]
682 fn leaves_scratch_that_is_no_path_inside_the_workspace() {
683 let temp = tempfile::tempdir().unwrap();
684 let workspace = temp.path().join("workspace");
685 std::fs::create_dir_all(temp.path().join("outside")).unwrap();
686 std::fs::write(temp.path().join("outside/kept"), "kept").unwrap();
687 for directory in [workspace.join("../outside"), temp.path().join("outside")] {
688 let scratch = crate::Scratch {
689 root: workspace.clone(),
690 directory,
691 };
692 assert!(empty_scratch(&[scratch]).is_err());
693 }
694 assert!(temp.path().join("outside/kept").exists());
695 }
696}