1use std::path::Path;
2use std::process::Stdio;
3use std::sync::Arc;
4use std::time::Duration;
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use tokio::io::AsyncReadExt;
9use tokio::process::Command;
10use tokio::sync::mpsc;
11use tokio::time::{Instant, sleep_until};
12use tokio_util::sync::CancellationToken;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct BashArgs {
16 pub command: String,
17 #[serde(default)]
20 pub timeout_seconds: Option<u64>,
21}
22
23#[derive(Debug, Clone)]
24pub struct BashOptions {
25 pub timeout: Duration,
27 pub max_timeout: Duration,
30 pub max_output_bytes: usize,
31}
32
33impl BashOptions {
34 fn resolve_timeout(&self, args: &BashArgs) -> (Duration, Option<u64>) {
38 let Some(requested) = args.timeout_seconds.filter(|seconds| *seconds > 0) else {
39 return (self.timeout.min(self.max_timeout), None);
40 };
41 let requested_duration = Duration::from_secs(requested);
42 if requested_duration > self.max_timeout {
43 (self.max_timeout, Some(requested))
44 } else {
45 (requested_duration, None)
46 }
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct BashResult {
52 pub output: String,
53 pub exit_code: Option<i32>,
54 pub timed_out: bool,
55 pub cancelled: bool,
56}
57
58pub type OutputSink = Arc<dyn Fn(String) + Send + Sync>;
59
60pub async fn execute_bash(
61 root: &Path,
62 args: &BashArgs,
63 options: &BashOptions,
64 output_sink: Option<OutputSink>,
65) -> Result<BashResult> {
66 execute_bash_cancellable(root, args, options, output_sink, CancellationToken::new()).await
67}
68
69pub async fn execute_bash_cancellable(
70 root: &Path,
71 args: &BashArgs,
72 options: &BashOptions,
73 output_sink: Option<OutputSink>,
74 cancel: CancellationToken,
75) -> Result<BashResult> {
76 let mut command = Command::new("bash");
77 command
78 .arg("-o")
79 .arg("pipefail")
80 .arg("-lc")
81 .arg(&args.command)
82 .current_dir(root)
83 .stdin(Stdio::null())
84 .stdout(Stdio::piped())
85 .stderr(Stdio::piped())
86 .kill_on_drop(true);
87 #[cfg(unix)]
88 command.process_group(0);
89 let mut child = command
90 .spawn()
91 .with_context(|| format!("start bash command: {}", args.command))?;
92
93 let stdout = child.stdout.take().context("capture bash stdout")?;
94 let stderr = child.stderr.take().context("capture bash stderr")?;
95 let (tx, mut rx) = mpsc::channel::<Vec<u8>>(32);
96 tokio::spawn(pump(stdout, tx.clone()));
97 tokio::spawn(pump(stderr, tx));
98
99 let (timeout, capped_from) = options.resolve_timeout(args);
100 let deadline = Instant::now() + timeout;
101 let mut bounded = BoundedOutput::new(options.max_output_bytes);
102 let mut timed_out = false;
103 let mut cancelled = false;
104 let status = loop {
105 tokio::select! {
106 status = child.wait() => break status.context("wait for bash command")?,
107 chunk = rx.recv() => {
108 if let Some(chunk) = chunk {
109 if let Some(sink) = &output_sink {
110 sink(String::from_utf8_lossy(&chunk).into_owned());
111 }
112 bounded.push(&chunk);
113 }
114 }
115 _ = sleep_until(deadline) => {
116 timed_out = true;
117 break terminate(&mut child).await.context("terminate timed out bash command")?;
118 }
119 _ = cancel.cancelled() => {
120 cancelled = true;
121 break terminate(&mut child).await.context("terminate cancelled bash command")?;
122 }
123 }
124 };
125 while let Some(chunk) = rx.recv().await {
126 if let Some(sink) = &output_sink {
127 sink(String::from_utf8_lossy(&chunk).into_owned());
128 }
129 bounded.push(&chunk);
130 }
131
132 let mut output = bounded.finish();
133 if timed_out {
134 output.push_str(&format!(
137 "\n[bash timed out after {:.1}s",
138 timeout.as_secs_f64()
139 ));
140 match capped_from {
141 Some(requested) => output.push_str(&format!(
142 "; timeout_seconds {requested} was capped at {}]",
143 options.max_timeout.as_secs()
144 )),
145 None => output.push_str(&format!(
146 "; pass timeout_seconds up to {} for slow commands]",
147 options.max_timeout.as_secs()
148 )),
149 }
150 } else if cancelled {
151 output.push_str("\n[bash cancelled]");
152 }
153 Ok(BashResult {
154 output,
155 exit_code: status.code(),
156 timed_out,
157 cancelled,
158 })
159}
160
161async fn terminate(child: &mut tokio::process::Child) -> Result<std::process::ExitStatus> {
162 #[cfg(unix)]
163 if let Some(pid) = child.id() {
164 let result = unsafe { libc::kill(-(pid as i32), libc::SIGTERM) };
166 if result != 0 {
167 let error = std::io::Error::last_os_error();
168 if error.raw_os_error() != Some(libc::ESRCH) {
169 return Err(error.into());
170 }
171 }
172 match tokio::time::timeout(Duration::from_secs(1), child.wait()).await {
173 Ok(status) => return Ok(status?),
174 Err(_) => {
175 unsafe { libc::kill(-(pid as i32), libc::SIGKILL) };
177 return Ok(child.wait().await?);
178 }
179 }
180 }
181 child.kill().await?;
182 Ok(child.wait().await?)
183}
184
185async fn pump(mut stream: impl tokio::io::AsyncRead + Unpin, tx: mpsc::Sender<Vec<u8>>) {
186 let mut buffer = vec![0_u8; 8192];
187 loop {
188 match stream.read(&mut buffer).await {
189 Ok(0) | Err(_) => break,
190 Ok(count) if tx.send(buffer[..count].to_vec()).await.is_err() => break,
191 Ok(_) => {}
192 }
193 }
194}
195
196struct BoundedOutput {
197 bytes: Vec<u8>,
198 max: usize,
199 dropped: usize,
200}
201
202impl BoundedOutput {
203 fn new(max: usize) -> Self {
204 Self {
205 bytes: Vec::new(),
206 max,
207 dropped: 0,
208 }
209 }
210
211 fn push(&mut self, chunk: &[u8]) {
212 self.bytes.extend_from_slice(chunk);
213 if self.bytes.len() > self.max {
214 let remove = self.bytes.len() - self.max;
215 self.bytes.drain(..remove);
216 self.dropped += remove;
217 }
218 }
219
220 fn finish(self) -> String {
221 let tail = String::from_utf8_lossy(&self.bytes);
222 if self.dropped == 0 {
223 tail.into_owned()
224 } else {
225 format!("[output truncated; dropped {} bytes]\n{tail}", self.dropped)
226 }
227 }
228}