1use std::{
4 collections::BTreeMap,
5 ffi::OsString,
6 future::Future,
7 io,
8 path::PathBuf,
9 process::{ExitStatus, Stdio},
10 time::Duration,
11};
12
13use thiserror::Error;
14use tokio::{
15 io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
16 time::{Instant, sleep, sleep_until, timeout},
17};
18use tokio_util::sync::CancellationToken;
19
20use crate::{
21 platform::{self, Child},
22 tools::human::log::{End, Lines, Run, Stream},
23};
24
25const OUTPUT_LIMIT: usize = 128 * 1024;
27const CLEANUP_GRACE: Duration = Duration::from_secs(1);
29const CAPTURE_BUFFER_BYTES: usize = 8192;
31
32#[derive(Debug, Clone)]
34pub struct Command {
35 pub program: OsString,
36 pub args: Vec<OsString>,
37 pub cwd: PathBuf,
38 pub env: BTreeMap<OsString, OsString>,
40 pub timeout: Duration,
41}
42
43#[derive(
47 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
48)]
49#[serde(transparent)]
50pub struct ProcessId(std::num::NonZeroU32);
51
52impl ProcessId {
53 pub fn new(pid: u32) -> Self {
55 Self(std::num::NonZeroU32::new(pid).expect("a real process id is never 0"))
56 }
57
58 pub fn get(self) -> u32 {
59 self.0.get()
60 }
61}
62
63impl From<std::num::NonZeroU32> for ProcessId {
64 fn from(pid: std::num::NonZeroU32) -> Self {
65 Self(pid)
66 }
67}
68
69impl std::fmt::Display for ProcessId {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 self.0.fmt(f)
72 }
73}
74
75impl rusqlite::types::ToSql for ProcessId {
76 fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
77 Ok(rusqlite::types::ToSqlOutput::from(i64::from(self.0.get())))
78 }
79}
80
81impl rusqlite::types::FromSql for ProcessId {
82 fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
83 let pid = u32::try_from(value.as_i64()?)
84 .map_err(|error| rusqlite::types::FromSqlError::Other(error.into()))?;
85 std::num::NonZeroU32::new(pid).map(Self).ok_or_else(|| {
86 rusqlite::types::FromSqlError::Other(
87 "a saved owner_pid of 0 is not a process id".into(),
88 )
89 })
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
95pub struct ChildIdentity {
96 pub pid: ProcessId,
97 pub start_time: u64,
100}
101
102#[derive(Debug)]
103pub struct Output {
104 pub status: ExitStatus,
105 pub stdout: Vec<u8>,
106 pub stderr: Vec<u8>,
107 pub truncated: bool,
108 pub duration: Duration,
109}
110
111pub(crate) fn tool_output<'a>(
115 program: &str,
116 args: impl IntoIterator<Item = &'a std::ffi::OsStr>,
117) -> Option<Vec<u8>> {
118 platform::program::tool_output(program, args)
119}
120
121pub(crate) struct Launcher {
125 pub env: BTreeMap<OsString, OsString>,
126}
127
128impl artifactize_tools::launch::Launcher for Launcher {
129 fn run<'a>(
130 &'a self,
131 launch: artifactize_tools::launch::Launch,
132 cancellation: &'a CancellationToken,
133 ) -> artifactize_tools::launch::Running<'a> {
134 use artifactize_tools::launch::{Finished, LaunchError};
135 Box::pin(async move {
136 let command = Command {
137 program: launch.program.into(),
138 args: launch.args,
139 cwd: launch.cwd,
140 env: self.env.clone(),
141 timeout: launch.timeout,
142 };
143 let ready = |_| async { Ok(()) };
144 match run_with_input_limit(
145 command,
146 None,
147 launch.output_limit,
148 cancellation.clone(),
149 ready,
150 )
151 .await
152 {
153 Ok(output) => Ok(Finished {
154 status: output.status,
155 stdout: output.stdout,
156 stderr: output.stderr,
157 truncated: output.truncated,
158 }),
159 Err(Error::Timeout) => Err(LaunchError::TimedOut),
160 Err(Error::Cancelled) => Err(LaunchError::Cancelled),
161 Err(error) => Err(LaunchError::Failed(
162 error
163 .argument_refusal()
164 .unwrap_or_else(|| error.to_string()),
165 )),
166 }
167 })
168 }
169}
170
171#[derive(Debug, Error)]
172pub enum Error {
173 #[error("process could not be spawned: {0}")]
174 Spawn(#[source] io::Error),
175 #[error("child registration failed: {0}")]
176 Registration(#[source] io::Error),
177 #[error("process supervision failed: {0}")]
178 Io(#[from] io::Error),
179 #[error("process cleanup could not be confirmed: {0}")]
180 Cleanup(#[source] io::Error),
181 #[error("process supervision task failed: {0}")]
182 Task(#[from] tokio::task::JoinError),
183 #[error("process timed out")]
184 Timeout,
185 #[error("process was cancelled")]
186 Cancelled,
187 #[error("process output pipes did not close after cleanup")]
188 OutputDidNotClose,
189}
190
191impl Error {
192 pub(crate) fn cleanup_confirmed(&self) -> bool {
195 match self {
196 Self::Cleanup(_) | Self::Task(_) => false,
197 Self::Spawn(_)
198 | Self::Registration(_)
199 | Self::Io(_)
200 | Self::Timeout
201 | Self::Cancelled
202 | Self::OutputDidNotClose => true,
203 }
204 }
205
206 pub(crate) fn argument_refusal(&self) -> Option<String> {
207 match self {
208 Self::Spawn(error) if error.kind() == io::ErrorKind::InvalidInput => Some(format!(
209 "Process arguments could not be passed safely: {error}"
210 )),
211 _ => None,
212 }
213 }
214}
215
216pub async fn run<F, R>(
220 command: Command,
221 cancellation: CancellationToken,
222 register: F,
223) -> Result<Output, Error>
224where
225 F: FnOnce(ChildIdentity) -> R + Send + 'static,
226 R: Future<Output = io::Result<()>> + Send + 'static,
227{
228 run_with_input(command, None, cancellation, register).await
229}
230
231pub(crate) async fn run_with_input<F, R>(
232 command: Command,
233 input: Option<Vec<u8>>,
234 cancellation: CancellationToken,
235 register: F,
236) -> Result<Output, Error>
237where
238 F: FnOnce(ChildIdentity) -> R + Send + 'static,
239 R: Future<Output = io::Result<()>> + Send + 'static,
240{
241 run_with_input_limit(command, input, OUTPUT_LIMIT, cancellation, register).await
242}
243
244pub(crate) async fn run_with_input_limit<F, R>(
245 command: Command,
246 input: Option<Vec<u8>>,
247 output_limit: usize,
248 cancellation: CancellationToken,
249 register: F,
250) -> Result<Output, Error>
251where
252 F: FnOnce(ChildIdentity) -> R + Send + 'static,
253 R: Future<Output = io::Result<()>> + Send + 'static,
254{
255 let cancellation = cancellation.child_token();
256 let _cancel_on_drop = cancellation.clone().drop_guard();
257 crate::task::joined(
258 tokio::spawn(run_inner(
259 command,
260 input,
261 output_limit,
262 Capture::default(),
263 cancellation,
264 register,
265 ))
266 .await,
267 )?
268}
269
270#[derive(Default)]
273struct Capture {
274 logs: Option<PathBuf>,
275 lines: Option<(Lines, Lines)>,
276}
277
278pub(crate) async fn run_watched(
281 command: Command,
282 cancellation: CancellationToken,
283 run: &Run,
284) -> Result<Output, Error> {
285 let cancellation = cancellation.child_token();
286 let _cancel_on_drop = cancellation.clone().drop_guard();
287 let capture = Capture {
288 logs: None,
289 lines: Some((run.lines(Stream::Stdout), run.lines(Stream::Stderr))),
290 };
291 crate::task::joined(
292 tokio::spawn(run_inner(
293 command,
294 None,
295 OUTPUT_LIMIT,
296 capture,
297 cancellation,
298 |_| async { Ok(()) },
299 ))
300 .await,
301 )?
302}
303
304async fn run_inner<F, R>(
305 command: Command,
306 input: Option<Vec<u8>>,
307 output_limit: usize,
308 Capture { logs, lines }: Capture,
309 cancellation: CancellationToken,
310 register: F,
311) -> Result<Output, Error>
312where
313 F: FnOnce(ChildIdentity) -> R,
314 R: Future<Output = io::Result<()>>,
315{
316 let started = Instant::now();
317 let deadline = started + command.timeout;
318 if cancellation.is_cancelled() {
319 return Err(Error::Cancelled);
320 }
321 if command.timeout.is_zero() {
322 return Err(Error::Timeout);
323 }
324
325 let mut child = spawn_owned(
326 &command,
327 input.is_some(),
328 cancellation.clone(),
329 deadline,
330 register,
331 )
332 .await?;
333
334 let stdout = child.stdout().take().expect("stdout is piped");
335 let stderr = child.stderr().take().expect("stderr is piped");
336 let stdin = child.stdin().take();
337 let reaped = CancellationToken::new();
338 let feed = async {
339 if let (Some(mut stdin), Some(input)) = (stdin, input) {
340 tokio::select! {
341 _ = reaped.cancelled() => {},
342 _ = cancellation.cancelled() => {},
343 _ = sleep_until(deadline) => {},
344 result = stdin.write_all(&input) => {
345 if let Err(error) = result && error.kind() != io::ErrorKind::BrokenPipe {
347 return Err(Error::Io(error));
348 }
349 }
350 }
351 }
352 Ok(())
353 };
354 let wait = async {
355 let result = tokio::select! {
356 biased;
357 _ = cancellation.cancelled() => Err(Error::Cancelled),
358 _ = sleep_until(deadline) => Err(Error::Timeout),
359 status = child.wait() => status.map_err(Error::Io),
360 };
361 let cleanup = if result.is_err() {
362 terminate(&mut child).await
363 } else {
364 child.cleanup().await.map(|_| ())
366 };
367 reaped.cancel();
368 cleanup.map_err(Error::Cleanup)?;
369 result
370 };
371 let (stdout_lines, stderr_lines) = lines.unzip();
372 let capture = async {
373 let read = async {
374 tokio::try_join!(
375 capture(
376 stdout,
377 output_limit,
378 logs.as_ref().map(|dir| dir.join("runtime.stdout")),
379 stdout_lines,
380 ),
381 capture(
382 stderr,
383 output_limit,
384 logs.as_ref().map(|dir| dir.join("runtime.stderr")),
385 stderr_lines,
386 )
387 )
388 };
389 tokio::pin!(read);
390 tokio::select! {
391 result = &mut read => result.map_err(Error::Io),
392 _ = reaped.cancelled() => {
393 timeout(CLEANUP_GRACE, read).await
394 .map_err(|_| Error::OutputDidNotClose)?
395 .map_err(Error::Io)
396 }
397 }
398 };
399 let (status, output, input) = tokio::join!(wait, capture, feed);
400 let status = status?;
401 input?;
402 let ((stdout, stdout_truncated), (stderr, stderr_truncated)) = output?;
403 Ok(Output {
404 status,
405 stdout,
406 stderr,
407 truncated: stdout_truncated || stderr_truncated,
408 duration: started.elapsed(),
409 })
410}
411
412pub(crate) async fn spawn_owned<F, R>(
415 command: &Command,
416 input: bool,
417 cancellation: CancellationToken,
418 deadline: Instant,
419 register: F,
420) -> Result<Child, Error>
421where
422 F: FnOnce(ChildIdentity) -> R,
423 R: Future<Output = io::Result<()>>,
424{
425 let program = platform::program::resolve_with(&command.program, &command.cwd, |name| {
426 environment_value(&command.env, name).map(ToOwned::to_owned)
427 })
428 .map_err(Error::Spawn)?;
429 let mut child = tokio::process::Command::new(&program);
430 child
431 .args(&command.args)
432 .current_dir(&command.cwd)
433 .env_clear()
434 .envs(&command.env)
435 .stdin(if input { Stdio::piped() } else { Stdio::null() })
436 .stdout(Stdio::piped())
437 .stderr(Stdio::piped())
438 .kill_on_drop(true);
439 let (gate, mut spawning) = platform::spawn_gated(child).map_err(Error::Spawn)?;
440 let admission = tokio::select! {
441 biased;
442 _ = cancellation.cancelled() => Err(Error::Cancelled),
443 _ = sleep_until(deadline) => Err(Error::Timeout),
444 result = async {
445 let pid = gate.pid().await?;
446 let child = child_identity(pid).map_err(Error::Registration)?;
447 register(child).await.map_err(Error::Registration)?;
448 Ok::<_, Error>(())
449 } => result,
450 };
451 let admission = admission.and_then(|()| {
453 if cancellation.is_cancelled() {
454 Err(Error::Cancelled)
455 } else if Instant::now() >= deadline {
456 Err(Error::Timeout)
457 } else {
458 gate.admit().map_err(Error::Io)
459 }
460 });
461 drop(gate);
462 let spawned = crate::task::joined((&mut spawning).await)?;
463 let mut child = match spawned {
464 Ok(child) => child,
465 Err(error) => {
466 return Err(match admission {
467 Err(error @ (Error::Registration(_) | Error::Cancelled | Error::Timeout)) => error,
468 _ => Error::Spawn(error),
469 });
470 }
471 };
472 if let Err(error) = admission {
473 terminate(&mut child).await.map_err(Error::Cleanup)?;
474 return Err(error);
475 }
476
477 Ok(child)
478}
479
480fn environment_value<'a>(
481 environment: &'a BTreeMap<OsString, OsString>,
482 name: &str,
483) -> Option<&'a std::ffi::OsStr> {
484 crate::platform::environment::lookup(environment, name)
485}
486
487pub(crate) fn launch_detached(
489 program: &std::ffi::OsStr,
490 args: &[String],
491 cwd: &std::path::Path,
492) -> Result<(), Error> {
493 let program = platform::program::resolve(program, cwd).map_err(Error::Spawn)?;
494 let mut command = tokio::process::Command::new(program);
495 command
496 .args(args)
497 .current_dir(cwd)
498 .env_clear()
499 .envs(platform::environment::snapshot())
500 .stdin(Stdio::null())
501 .stdout(Stdio::null())
502 .stderr(Stdio::null())
503 .kill_on_drop(false);
504 let mut child = platform::spawn_detached(command).map_err(Error::Spawn)?;
505 tokio::spawn(async move {
506 let _ = child.wait().await;
507 });
508 Ok(())
509}
510
511pub(crate) fn launch_watched(
516 program: &std::ffi::OsStr,
517 args: &[String],
518 cwd: &std::path::Path,
519 run: Run,
520) -> Result<(), (Error, Run)> {
521 if !run.watched() {
522 return match launch_detached(program, args, cwd) {
523 Ok(()) => {
524 run.end(End::Launched);
525 Ok(())
526 }
527 Err(error) => Err((error, run)),
528 };
529 }
530 let program = match platform::program::resolve(program, cwd) {
531 Ok(program) => program,
532 Err(error) => return Err((Error::Spawn(error), run)),
533 };
534 let mut command = tokio::process::Command::new(program);
535 command
536 .args(args)
537 .current_dir(cwd)
538 .env_clear()
539 .envs(platform::environment::snapshot())
540 .stdin(Stdio::null())
541 .stdout(Stdio::piped())
542 .stderr(Stdio::piped())
543 .kill_on_drop(false);
544 let mut child = match platform::spawn_watched(command) {
545 Ok(child) => child,
546 Err(error) => return Err((Error::Spawn(error), run)),
547 };
548 let stdout = child.stdout.take().expect("stdout is piped");
549 let stderr = child.stderr.take().expect("stderr is piped");
550 tokio::spawn(async move {
551 let (status, _, _) = tokio::join!(
553 child.wait(),
554 capture(stdout, 0, None, Some(run.lines(Stream::Stdout))),
555 capture(stderr, 0, None, Some(run.lines(Stream::Stderr))),
556 );
557 run.end(match status {
558 Ok(status) => End::exited(&status),
559 Err(error) => End::Failed(error.to_string()),
560 });
561 });
562 Ok(())
563}
564
565pub(crate) fn artifactize_executable() -> Result<std::path::PathBuf, String> {
567 let current = std::env::current_exe().map_err(|e| e.to_string())?;
568 for directory in current
569 .parent()
570 .into_iter()
571 .flat_map(|parent| [Some(parent), parent.parent()])
572 .flatten()
573 {
574 if let Some(candidate) = platform::program::candidates(&directory.join("artifactize"))
575 .into_iter()
576 .find(|path| path.is_file())
577 {
578 return Ok(candidate);
579 }
580 }
581 platform::program::resolve(
582 std::ffi::OsStr::new("artifactize"),
583 &std::env::current_dir().map_err(|e| e.to_string())?,
584 )
585 .map_err(|e| e.to_string())
586}
587
588#[derive(Clone, Copy)]
590pub(crate) enum RunEntry {
591 Supervisor,
592 Executor,
593}
594pub(crate) fn spawn_run(
595 program: &std::path::Path,
596 id: &crate::types::RunId,
597 repo: &std::path::Path,
598 state: &std::path::Path,
599 entry: RunEntry,
600) -> Result<(ChildIdentity, tokio::process::Child), Error> {
601 let mut options = platform::private_options();
602 options.write(true);
603 match entry {
604 RunEntry::Supervisor => {
605 options.create_new(true);
606 }
607 RunEntry::Executor => {
608 options.append(true);
609 }
610 }
611 let directory = state.join("runs").join(id.as_str());
612 let stderr = options
613 .open(directory.join("worker.stderr"))
614 .map_err(Error::Spawn)?;
615 let program = platform::program::resolve(program.as_os_str(), repo).map_err(Error::Spawn)?;
616 let mut command = tokio::process::Command::new(program);
617 command
618 .arg("--repo")
619 .arg(repo)
620 .arg("--state-dir")
621 .arg(state)
622 .arg(match entry {
623 RunEntry::Supervisor => "__verify",
624 RunEntry::Executor => "__execute",
625 })
626 .arg(id.as_str())
627 .current_dir(directory)
630 .env_clear()
631 .envs(platform::environment::snapshot())
632 .stdin(Stdio::null())
633 .stdout(Stdio::null())
634 .stderr(Stdio::from(stderr))
635 .kill_on_drop(false);
636 let child = platform::spawn_run(command).map_err(Error::Spawn)?;
637 let identity = child_identity(child.id().expect("spawned worker has a PID"))
638 .map_err(Error::Registration)?;
639 Ok((identity, child))
640}
641pub(crate) fn launch_run(
642 program: &std::path::Path,
643 id: &crate::types::RunId,
644 repo: &std::path::Path,
645 state: &std::path::Path,
646) -> Result<ChildIdentity, Error> {
647 let (identity, mut child) = spawn_run(program, id, repo, state, RunEntry::Supervisor)?;
648 tokio::spawn(async move {
649 let _ = child.wait().await;
650 });
651 Ok(identity)
652}
653
654pub(crate) fn child_identity(pid: u32) -> io::Result<ChildIdentity> {
655 let start_time = platform::process_start_time(pid)?;
656 Ok(ChildIdentity {
657 pid: ProcessId::new(pid),
658 start_time,
659 })
660}
661
662pub(crate) fn is_alive(owner: Option<ChildIdentity>) -> io::Result<bool> {
665 let Some(owner) = owner else {
666 return Ok(false);
667 };
668 match platform::running_process_start_time(owner.pid.get()) {
669 Ok(start_time) => Ok(start_time == owner.start_time),
670 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
671 Err(error) => Err(error),
672 }
673}
674
675async fn terminate(child: &mut Child) -> io::Result<()> {
676 let _ = child.interrupt();
677 tokio::select! {
678 _ = sleep(CLEANUP_GRACE) => {},
679 _ = child.wait() => {},
680 }
681 child.cleanup().await?;
682 Ok(())
683}
684
685pub(crate) async fn run_logged<F, R>(
688 command: Command,
689 directory: PathBuf,
690 cancellation: CancellationToken,
691 register: F,
692) -> Result<Output, Error>
693where
694 F: FnOnce(ChildIdentity) -> R + Send + 'static,
695 R: Future<Output = io::Result<()>> + Send + 'static,
696{
697 let cancellation = cancellation.child_token();
698 let _cancel_on_drop = cancellation.clone().drop_guard();
699 crate::task::joined(
700 tokio::spawn(run_inner(
701 command,
702 None,
703 OUTPUT_LIMIT,
704 Capture {
705 logs: Some(directory),
706 lines: None,
707 },
708 cancellation,
709 register,
710 ))
711 .await,
712 )?
713}
714
715async fn capture(
716 mut pipe: impl AsyncRead + Unpin,
717 limit: usize,
718 path: Option<PathBuf>,
719 mut lines: Option<Lines>,
720) -> io::Result<(Vec<u8>, bool)> {
721 let mut log = path
722 .as_ref()
723 .map(|path| {
724 let mut options = platform::private_options();
725 options.write(true).create_new(true);
726 options.open(path).map(tokio::fs::File::from_std)
727 })
728 .transpose()?;
729 let mut output = Vec::new();
730 let mut buffer = [0; CAPTURE_BUFFER_BYTES];
731 let mut truncated = false;
732 loop {
733 let count = pipe.read(&mut buffer).await?;
734 if count == 0 {
735 if let Some(lines) = &mut lines {
736 lines.finish();
737 }
738 if let (Some(log), Some(path)) = (&mut log, &path) {
739 log.flush().await?;
740 let mut options = platform::private_options();
741 options.write(true).create_new(true);
742 let extension = path.extension().expect("stream extension");
743 options.open(
744 path.with_extension(format!("{}.complete", extension.to_string_lossy())),
745 )?;
746 }
747 return Ok((output, truncated));
748 }
749 if let Some(log) = &mut log {
750 log.write_all(&buffer[..count]).await?;
751 log.flush().await?;
753 }
754 if let Some(lines) = &mut lines {
755 lines.push(&buffer[..count]);
756 }
757 let retained = count.min(limit - output.len());
758 output.extend_from_slice(&buffer[..retained]);
759 truncated |= retained < count;
760 }
761}