Skip to main content

crok_lib/
command.rs

1use std::{
2    collections::HashMap,
3    process::{Command, ExitStatus},
4    time::{Duration, Instant},
5};
6
7use procstream::{Capture, CommandJobExt, Event, Signal, Stream};
8use serde::Serialize;
9use shellish_parse::ParseOptions;
10use termcolor::Color;
11
12use crate::{
13    cwrite, cwriteln,
14    output::Lines,
15    script::{ScriptKillReceiver, ScriptKillSender, ScriptLocation},
16};
17
18#[derive(Copy, Clone, derive_more::Debug, PartialEq, Eq)]
19pub enum CommandResult {
20    #[debug("{_0:?}")]
21    Exit(ExitStatus, bool),
22    #[debug("timed out")]
23    TimedOut,
24}
25
26impl CommandResult {
27    pub fn success(&self) -> bool {
28        match self {
29            CommandResult::Exit(status, _) => status.success(),
30            CommandResult::TimedOut => false,
31        }
32    }
33}
34
35impl std::fmt::Display for CommandResult {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            CommandResult::Exit(status, killed) => {
39                if *killed {
40                    write!(f, "killed")?;
41                    // On Unix the status also names the signal.
42                    #[cfg(unix)]
43                    {
44                        use std::os::unix::process::ExitStatusExt;
45                        if status.signal().is_some() {
46                            write!(f, "; {status}")?;
47                        }
48                    }
49                    Ok(())
50                } else {
51                    write!(f, "{status}")
52                }
53            }
54            CommandResult::TimedOut => write!(f, "timed out"),
55        }
56    }
57}
58
59#[derive(Clone, Debug, Serialize)]
60#[serde(transparent)]
61pub struct CommandLine {
62    pub command: String,
63    #[serde(skip)]
64    pub location: ScriptLocation,
65    #[serde(skip)]
66    pub line_count: usize,
67}
68
69impl CommandLine {
70    pub fn new(command: String, location: ScriptLocation, line_count: usize) -> Self {
71        Self {
72            command,
73            location,
74            line_count,
75        }
76    }
77
78    #[allow(clippy::too_many_arguments)]
79    pub fn run(
80        &self,
81        writer: &mut dyn termcolor::WriteColor,
82        show_line_numbers: bool,
83        runner: Option<String>,
84        timeout: Duration,
85        envs: &HashMap<String, String>,
86        kill_receiver: &ScriptKillReceiver,
87        kill_sender: &ScriptKillSender,
88    ) -> Result<(Lines, CommandResult), std::io::Error> {
89        let start = Instant::now();
90        let warn_time = timeout.saturating_mul(90) / 100;
91        let timeout = timeout.saturating_mul(110) / 100;
92
93        let mut command = if let Some(runner) = runner {
94            let bits = shellish_parse::parse(&runner, ParseOptions::default())
95                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
96            let mut cmd = Command::new(&bits[0]);
97            cmd.args(&bits[1..]);
98            cmd
99        } else {
100            let mut cmd = Command::new("sh");
101            cmd.arg("-c");
102            cmd
103        };
104        command.arg(&self.command);
105        command.envs(envs);
106        if let Some(pwd) = envs.get("PWD") {
107            command.current_dir(pwd);
108        }
109
110        // Spawn into an isolated job (a new process group / Job object) with each
111        // line of stdout and stderr delivered as a chunk.
112        let (mut child, output) = command.spawn_job(Capture::lines())?;
113
114        let job = child.job().clone();
115
116        // Watch the script-wide kill flag and bring the whole tree down if it is
117        // set, while we consume the command's output on this thread. Terminate
118        // gracefully, then hard-kill anything that ignores it.
119        let result = kill_receiver.run_with(
120            || _ = job.shutdown(Signal::Terminate, Duration::from_millis(250)),
121            move || {
122                let mut line_number = 1;
123                let mut output_lines = vec![];
124                let mut warned = false;
125
126                // The framer delivers whole lines: it truncates an over-long
127                // line to one Overlong-tagged line and strips a bare trailing
128                // CR at end of stream, so nothing to stitch or trim here.
129                let mut push_line = |stream: Stream, line: String| {
130                    if show_line_numbers {
131                        cwrite!(
132                            writer,
133                            fg = Color::White,
134                            dimmed = true,
135                            "{line_number:>3} "
136                        );
137                    }
138
139                    let line_out = fast_strip_ansi::strip_ansi_string(&line);
140                    if stream == Stream::Stdout {
141                        cwriteln!(writer, fg = Color::White, "{line_out}");
142                    } else {
143                        cwriteln!(writer, fg = Color::Yellow, "{line_out}");
144                    }
145
146                    output_lines.push(line);
147                    line_number += 1;
148                };
149
150                loop {
151                    // Check the deadline every pass, so a silent command cannot
152                    // outrun it while we wait for output or exit.
153                    if start.elapsed() >= timeout {
154                        cwriteln!(writer, fg = Color::Yellow, "Process took too long!");
155                        kill_sender.kill();
156                        _ = child.shutdown(Signal::Terminate, Duration::from_millis(250));
157                        return Ok((Lines::new(output_lines), CommandResult::TimedOut));
158                    }
159
160                    // Wake at the warning threshold (once), then again at the hard
161                    // timeout, even if the command is producing no output.
162                    let remaining = timeout.saturating_sub(start.elapsed());
163                    let wait = if warned {
164                        remaining
165                    } else {
166                        remaining.min(warn_time.saturating_sub(start.elapsed()))
167                    };
168
169                    match output.recv_timeout(wait) {
170                        Ok(Event::Chunk(chunk)) => {
171                            let stream = chunk.stream;
172                            // Move the bytes into a String, copying only when
173                            // invalid UTF-8 forces a lossy pass.
174                            let line = String::from_utf8(chunk.item.bytes).unwrap_or_else(|e| {
175                                String::from_utf8_lossy(e.as_bytes()).into_owned()
176                            });
177                            push_line(stream, line);
178                        }
179                        // The leader may exit before we have drained every chunk.
180                        Ok(Event::Exit(_)) => {}
181                        Err(procstream::RecvTimeout::Closed) => break,
182                        Err(procstream::RecvTimeout::Timeout) => {
183                            if !warned && start.elapsed() < timeout {
184                                eprintln!("Process #{} taking too long to finish.", child.id());
185                                warned = true;
186                            }
187                        }
188                    }
189                }
190
191                let status = child.wait()?;
192                Ok((Lines::new(output_lines), CommandResult::Exit(status, false)))
193            },
194        );
195
196        // `run_with` has joined the kill watcher, so read the flag here rather
197        // than in the closure, where it would race the watcher that sets it.
198        match result {
199            Ok((lines, CommandResult::Exit(status, _))) => {
200                Ok((lines, CommandResult::Exit(status, job.terminated())))
201            }
202            other => other,
203        }
204    }
205}