artifactize_core/process/
capture.rs1use std::{io, path::PathBuf, time::Duration};
6
7use tokio::{
8 io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
9 time::{Instant, timeout},
10};
11use tokio_util::sync::CancellationToken;
12
13use crate::{
14 platform,
15 tools::human::log::{Lines, Stream},
16};
17
18const CAPTURE_BUFFER_BYTES: usize = 8192;
20
21pub(crate) const OUTPUT_CLOSE_GRACE: Duration = Duration::from_secs(5);
27
28#[derive(Debug)]
30pub(super) enum PipeEnd {
31 Closed,
33 Open,
35 Failed(io::Error),
36}
37
38#[derive(Debug)]
41pub(super) struct Captured {
42 pub output: Vec<u8>,
43 pub truncated: bool,
44 pub pipe: PipeEnd,
45 pub evidence: io::Result<()>,
46}
47
48pub(super) async fn capture(
52 mut pipe: impl AsyncRead + Unpin,
53 limit: usize,
54 path: Option<PathBuf>,
55 mut lines: Option<Lines>,
56 gone: &CancellationToken,
57) -> Captured {
58 let mut evidence = Evidence::open(path);
59 let mut output = Vec::new();
60 let mut buffer = [0; CAPTURE_BUFFER_BYTES];
61 let mut truncated = false;
62 let mut grace = OUTPUT_CLOSE_GRACE;
63 let pipe_end = loop {
64 let count = match read(&mut pipe, &mut buffer, gone, &mut grace).await {
65 Some(Ok(0)) => break PipeEnd::Closed,
66 Some(Ok(count)) => count,
67 Some(Err(error)) => break PipeEnd::Failed(error),
68 None => break PipeEnd::Open,
69 };
70 let chunk = &buffer[..count];
71 evidence.write(chunk).await;
72 if let Some(lines) = &mut lines {
73 lines.push(chunk);
74 }
75 let retained = count.min(limit - output.len());
76 output.extend_from_slice(&chunk[..retained]);
77 truncated |= retained < count;
78 };
79 if let Some(lines) = &mut lines {
80 lines.finish();
81 }
82 let evidence = evidence.finish(matches!(pipe_end, PipeEnd::Closed)).await;
83 Captured {
84 output,
85 truncated,
86 pipe: pipe_end,
87 evidence,
88 }
89}
90
91async fn read(
94 pipe: &mut (impl AsyncRead + Unpin),
95 buffer: &mut [u8],
96 gone: &CancellationToken,
97 grace: &mut Duration,
98) -> Option<io::Result<usize>> {
99 if !gone.is_cancelled() {
100 tokio::select! {
102 biased;
103 read = pipe.read(buffer) => return Some(read),
104 () = gone.cancelled() => {}
105 }
106 }
107 let started = Instant::now();
108 let read = timeout(*grace, pipe.read(buffer)).await;
109 *grace = grace.saturating_sub(started.elapsed());
110 read.ok()
111}
112
113struct Evidence {
116 file: Option<(PathBuf, tokio::fs::File)>,
117 failure: Option<io::Error>,
118}
119
120impl Evidence {
121 fn open(path: Option<PathBuf>) -> Self {
122 let Some(path) = path else {
123 return Self {
124 file: None,
125 failure: None,
126 };
127 };
128 let mut options = platform::private_options();
129 options.write(true).create_new(true);
130 match options.open(&path) {
131 Ok(file) => Self {
132 file: Some((path, tokio::fs::File::from_std(file))),
133 failure: None,
134 },
135 Err(error) => Self {
136 file: None,
137 failure: Some(error),
138 },
139 }
140 }
141
142 async fn write(&mut self, chunk: &[u8]) {
143 let Some((_, file)) = &mut self.file else {
144 return;
145 };
146 #[cfg(test)]
147 slow_disk::wait().await;
148 let written = async {
150 file.write_all(chunk).await?;
151 file.flush().await
152 };
153 if let Err(error) = written.await {
154 self.file = None;
155 self.failure = Some(error);
156 }
157 }
158
159 async fn finish(self, closed: bool) -> io::Result<()> {
161 if let Some(failure) = self.failure {
162 return Err(failure);
163 }
164 let Some((path, mut file)) = self.file else {
165 return Ok(());
166 };
167 #[cfg(test)]
168 slow_disk::wait().await;
169 file.flush().await?;
170 if closed {
171 let mut options = platform::private_options();
172 options.write(true).create_new(true);
173 let extension = path.extension().expect("stream extension");
174 options
175 .open(path.with_extension(format!("{}.complete", extension.to_string_lossy())))?;
176 }
177 Ok(())
178 }
179}
180
181#[derive(Debug)]
183pub enum StreamFailure {
184 Open,
186 Read(io::Error),
188 Evidence(io::Error),
190}
191
192#[derive(Debug, thiserror::Error)]
194#[error("process output could not be captured: {}; {}", failure_text(&self.failures), end_text(&self.output.status))]
195pub struct CaptureError {
196 pub output: super::Output,
197 pub failures: Vec<(Stream, StreamFailure)>,
199}
200
201fn failure_text(failures: &[(Stream, StreamFailure)]) -> String {
202 failures
203 .iter()
204 .map(|(stream, failure)| {
205 let stream = match stream {
206 Stream::Stdout => "stdout",
207 Stream::Stderr => "stderr",
208 };
209 match failure {
210 StreamFailure::Open => format!(
211 "{stream} was still open {}s after the process group was gone, so a process \
212 outside the group holds it",
213 OUTPUT_CLOSE_GRACE.as_secs()
214 ),
215 StreamFailure::Read(error) => format!("{stream} could not be read: {error}"),
216 StreamFailure::Evidence(error) => {
217 format!("{stream} could not be saved: {error}")
218 }
219 }
220 })
221 .collect::<Vec<_>>()
222 .join("; ")
223}
224
225fn end_text(status: &std::process::ExitStatus) -> String {
226 format!(
227 "the process had ended ({})",
228 platform::exit_description(status)
229 )
230}
231
232impl Captured {
233 fn split(self) -> ((Vec<u8>, bool), impl Iterator<Item = StreamFailure>) {
235 let pipe = match self.pipe {
236 PipeEnd::Closed => None,
237 PipeEnd::Open => Some(StreamFailure::Open),
238 PipeEnd::Failed(error) => Some(StreamFailure::Read(error)),
239 };
240 let evidence = self.evidence.err().map(StreamFailure::Evidence);
241 (
242 (self.output, self.truncated),
243 pipe.into_iter().chain(evidence),
244 )
245 }
246}
247
248pub(super) fn finish(
250 status: std::process::ExitStatus,
251 stdout: Captured,
252 stderr: Captured,
253 duration: Duration,
254) -> Result<super::Output, super::Error> {
255 let ((stdout, stdout_truncated), stdout_failures) = stdout.split();
256 let ((stderr, stderr_truncated), stderr_failures) = stderr.split();
257 let failures: Vec<_> = stdout_failures
258 .map(|failure| (Stream::Stdout, failure))
259 .chain(stderr_failures.map(|failure| (Stream::Stderr, failure)))
260 .collect();
261 let output = super::Output {
262 status,
263 stdout,
264 stderr,
265 truncated: stdout_truncated || stderr_truncated,
266 duration,
267 };
268 if failures.is_empty() {
269 Ok(output)
270 } else {
271 Err(super::Error::Capture(Box::new(CaptureError {
272 output,
273 failures,
274 })))
275 }
276}
277
278#[cfg(test)]
281pub(crate) mod slow_disk {
282 use std::{cell::RefCell, sync::Arc};
283
284 use tokio::sync::watch;
285
286 type Hold = (watch::Receiver<bool>, Arc<()>);
288
289 thread_local! {
290 static HOLD: RefCell<Option<Hold>> = const { RefCell::new(None) };
291 }
292
293 pub(crate) struct SlowDisk {
295 release: watch::Sender<bool>,
296 waiting: Arc<()>,
297 }
298
299 impl SlowDisk {
300 pub(crate) fn waiting(&self) -> usize {
302 Arc::strong_count(&self.waiting) - 2
304 }
305
306 pub(crate) fn release(&self) {
307 self.release.send_replace(true);
308 }
309 }
310
311 pub(crate) fn hold() -> SlowDisk {
312 let (release, held) = watch::channel(false);
313 let waiting = Arc::new(());
314 HOLD.with(|hold| *hold.borrow_mut() = Some((held, waiting.clone())));
315 SlowDisk { release, waiting }
316 }
317
318 pub(super) async fn wait() {
319 let held = HOLD.with(|hold| hold.borrow().clone());
320 if let Some((mut held, waiting)) = held {
321 let _ = held.wait_for(|released| *released).await;
322 drop(waiting);
323 }
324 }
325}