Skip to main content

artifactize_core/
process.rs

1//! Literal process launches, bounded capture, and process-group cleanup.
2
3use 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::platform::{self, Child};
21
22/// Retain bounded stdout/stderr audit per stream while continuing to drain the pipes.
23const OUTPUT_LIMIT: usize = 128 * 1024;
24/// Give interrupted children one second to exit cooperatively before killing/reaping them.
25const CLEANUP_GRACE: Duration = Duration::from_secs(1);
26/// Drain process pipes in small bounded chunks without one read per byte.
27const CAPTURE_BUFFER_BYTES: usize = 8192;
28
29/// Arguments have already been resolved by the caller. No shell is added.
30#[derive(Debug, Clone)]
31pub struct Command {
32    pub program: OsString,
33    pub args: Vec<OsString>,
34    pub cwd: PathBuf,
35    /// The complete child environment, not additions to the parent's environment.
36    pub env: BTreeMap<OsString, OsString>,
37    pub timeout: Duration,
38}
39
40/// A process id, guaranteed nonzero: every OS reserves 0 for the kernel or an idle
41/// process, never one artifactize spawns or supervises. `#[serde(transparent)]` keeps
42/// the saved and wire representation the same plain integer.
43#[derive(
44    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
45)]
46#[serde(transparent)]
47pub struct ProcessId(std::num::NonZeroU32);
48
49impl ProcessId {
50    /// `pid` is a real, just-observed OS process id, so it is never 0.
51    pub fn new(pid: u32) -> Self {
52        Self(std::num::NonZeroU32::new(pid).expect("a real process id is never 0"))
53    }
54
55    pub fn get(self) -> u32 {
56        self.0.get()
57    }
58}
59
60impl From<std::num::NonZeroU32> for ProcessId {
61    fn from(pid: std::num::NonZeroU32) -> Self {
62        Self(pid)
63    }
64}
65
66impl std::fmt::Display for ProcessId {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        self.0.fmt(f)
69    }
70}
71
72impl rusqlite::types::ToSql for ProcessId {
73    fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
74        Ok(rusqlite::types::ToSqlOutput::from(i64::from(self.0.get())))
75    }
76}
77
78impl rusqlite::types::FromSql for ProcessId {
79    fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
80        let pid = u32::try_from(value.as_i64()?)
81            .map_err(|error| rusqlite::types::FromSqlError::Other(error.into()))?;
82        std::num::NonZeroU32::new(pid).map(Self).ok_or_else(|| {
83            rusqlite::types::FromSqlError::Other(
84                "a saved owner_pid of 0 is not a process id".into(),
85            )
86        })
87    }
88}
89
90/// Process id and start time, captured while the group leader is still inert.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
92pub struct ChildIdentity {
93    pub pid: ProcessId,
94    /// Linux: clock ticks since boot (procfs field 22). macOS: microseconds since the
95    /// epoch (kernel birth timestamp). Windows: 100 ns intervals since 1601.
96    pub start_time: u64,
97}
98
99#[derive(Debug)]
100pub struct Output {
101    pub status: ExitStatus,
102    pub stdout: Vec<u8>,
103    pub stderr: Vec<u8>,
104    pub truncated: bool,
105    pub duration: Duration,
106}
107
108/// Run a program artifactize itself uses, such as git, found through the shared program
109/// lookup, and return its standard output when it succeeds. `None` when it is missing or
110/// fails.
111pub(crate) fn tool_output<'a>(
112    program: &str,
113    args: impl IntoIterator<Item = &'a std::ffi::OsStr>,
114) -> Option<Vec<u8>> {
115    platform::program::tool_output(program, args)
116}
117
118/// Starts a built-in's program, such as `help`'s, through this process layer: admitted
119/// before it runs, kept in one process group or Job Object and cleaned up with its children,
120/// with exactly the environment given here.
121pub(crate) struct Launcher {
122    pub env: BTreeMap<OsString, OsString>,
123}
124
125impl artifactize_tools::launch::Launcher for Launcher {
126    fn run<'a>(
127        &'a self,
128        launch: artifactize_tools::launch::Launch,
129        cancellation: &'a CancellationToken,
130    ) -> artifactize_tools::launch::Running<'a> {
131        use artifactize_tools::launch::{Finished, LaunchError};
132        Box::pin(async move {
133            let command = Command {
134                program: launch.program.into(),
135                args: launch.args,
136                cwd: launch.cwd,
137                env: self.env.clone(),
138                timeout: launch.timeout,
139            };
140            let ready = |_| async { Ok(()) };
141            match run_with_input_limit(
142                command,
143                None,
144                launch.output_limit,
145                cancellation.clone(),
146                ready,
147            )
148            .await
149            {
150                Ok(output) => Ok(Finished {
151                    status: output.status,
152                    stdout: output.stdout,
153                    stderr: output.stderr,
154                    truncated: output.truncated,
155                }),
156                Err(Error::Timeout) => Err(LaunchError::TimedOut),
157                Err(Error::Cancelled) => Err(LaunchError::Cancelled),
158                Err(error) => Err(LaunchError::Failed(
159                    error
160                        .argument_refusal()
161                        .unwrap_or_else(|| error.to_string()),
162                )),
163            }
164        })
165    }
166}
167
168#[derive(Debug, Error)]
169pub enum Error {
170    #[error("process could not be spawned: {0}")]
171    Spawn(#[source] io::Error),
172    #[error("child registration failed: {0}")]
173    Registration(#[source] io::Error),
174    #[error("process supervision failed: {0}")]
175    Io(#[from] io::Error),
176    #[error("process cleanup could not be confirmed: {0}")]
177    Cleanup(#[source] io::Error),
178    #[error("process supervision task failed: {0}")]
179    Task(#[from] tokio::task::JoinError),
180    #[error("process timed out")]
181    Timeout,
182    #[error("process was cancelled")]
183    Cancelled,
184    #[error("process output pipes did not close after cleanup")]
185    OutputDidNotClose,
186}
187
188impl Error {
189    /// Every ordinary run error is returned only after cleanup or before admission.
190    /// Cleanup/task failures are the exceptions: retain any admission record.
191    pub(crate) fn cleanup_confirmed(&self) -> bool {
192        match self {
193            Self::Cleanup(_) | Self::Task(_) => false,
194            Self::Spawn(_)
195            | Self::Registration(_)
196            | Self::Io(_)
197            | Self::Timeout
198            | Self::Cancelled
199            | Self::OutputDidNotClose => true,
200        }
201    }
202
203    pub(crate) fn argument_refusal(&self) -> Option<String> {
204        match self {
205            Self::Spawn(error) if error.kind() == io::ErrorKind::InvalidInput => Some(format!(
206                "Process arguments could not be passed safely: {error}"
207            )),
208            _ => None,
209        }
210    }
211}
212
213/// Register the inert child before allowing exec. A rejected, cancelled, or timed-out
214/// registration cannot start user code. The callback must not block its executor.
215/// Dropping this future cancels execution; its supervisor still kills and reaps it.
216pub async fn run<F, R>(
217    command: Command,
218    cancellation: CancellationToken,
219    register: F,
220) -> Result<Output, Error>
221where
222    F: FnOnce(ChildIdentity) -> R + Send + 'static,
223    R: Future<Output = io::Result<()>> + Send + 'static,
224{
225    run_with_input(command, None, cancellation, register).await
226}
227
228pub(crate) async fn run_with_input<F, R>(
229    command: Command,
230    input: Option<Vec<u8>>,
231    cancellation: CancellationToken,
232    register: F,
233) -> Result<Output, Error>
234where
235    F: FnOnce(ChildIdentity) -> R + Send + 'static,
236    R: Future<Output = io::Result<()>> + Send + 'static,
237{
238    run_with_input_limit(command, input, OUTPUT_LIMIT, cancellation, register).await
239}
240
241pub(crate) async fn run_with_input_limit<F, R>(
242    command: Command,
243    input: Option<Vec<u8>>,
244    output_limit: usize,
245    cancellation: CancellationToken,
246    register: F,
247) -> Result<Output, Error>
248where
249    F: FnOnce(ChildIdentity) -> R + Send + 'static,
250    R: Future<Output = io::Result<()>> + Send + 'static,
251{
252    let cancellation = cancellation.child_token();
253    let _cancel_on_drop = cancellation.clone().drop_guard();
254    crate::task::joined(
255        tokio::spawn(run_inner(
256            command,
257            input,
258            output_limit,
259            cancellation,
260            register,
261        ))
262        .await,
263    )?
264}
265
266async fn run_inner<F, R>(
267    command: Command,
268    input: Option<Vec<u8>>,
269    output_limit: usize,
270    cancellation: CancellationToken,
271    register: F,
272) -> Result<Output, Error>
273where
274    F: FnOnce(ChildIdentity) -> R,
275    R: Future<Output = io::Result<()>>,
276{
277    let started = Instant::now();
278    let deadline = started + command.timeout;
279    if cancellation.is_cancelled() {
280        return Err(Error::Cancelled);
281    }
282    if command.timeout.is_zero() {
283        return Err(Error::Timeout);
284    }
285
286    let mut child = spawn_owned(
287        &command,
288        input.is_some(),
289        cancellation.clone(),
290        deadline,
291        register,
292    )
293    .await?;
294
295    let stdout = child.stdout().take().expect("stdout is piped");
296    let stderr = child.stderr().take().expect("stderr is piped");
297    let stdin = child.stdin().take();
298    let reaped = CancellationToken::new();
299    let feed = async {
300        if let (Some(mut stdin), Some(input)) = (stdin, input) {
301            tokio::select! {
302                _ = reaped.cancelled() => {},
303                _ = cancellation.cancelled() => {},
304                _ = sleep_until(deadline) => {},
305                result = stdin.write_all(&input) => {
306                    // Fingerprint scripts may intentionally ignore their context.
307                    if let Err(error) = result && error.kind() != io::ErrorKind::BrokenPipe {
308                        return Err(Error::Io(error));
309                    }
310                }
311            }
312        }
313        Ok(())
314    };
315    let wait = async {
316        let result = tokio::select! {
317            biased;
318            _ = cancellation.cancelled() => Err(Error::Cancelled),
319            _ = sleep_until(deadline) => Err(Error::Timeout),
320            status = child.wait() => status.map_err(Error::Io),
321        };
322        let cleanup = if result.is_err() {
323            terminate(&mut child).await
324        } else {
325            // An ordinary main-process exit must not leave its descendants running.
326            child.cleanup().await.map(|_| ())
327        };
328        reaped.cancel();
329        cleanup.map_err(Error::Cleanup)?;
330        result
331    };
332    let capture = async {
333        let read = async {
334            tokio::try_join!(capture(stdout, output_limit), capture(stderr, output_limit))
335        };
336        tokio::pin!(read);
337        tokio::select! {
338            result = &mut read => result.map_err(Error::Io),
339            _ = reaped.cancelled() => {
340                timeout(CLEANUP_GRACE, read).await
341                    .map_err(|_| Error::OutputDidNotClose)?
342                    .map_err(Error::Io)
343            }
344        }
345    };
346    let (status, output, input) = tokio::join!(wait, capture, feed);
347    let status = status?;
348    input?;
349    let ((stdout, stdout_truncated), (stderr, stderr_truncated)) = output?;
350    Ok(Output {
351        status,
352        stdout,
353        stderr,
354        truncated: stdout_truncated || stderr_truncated,
355        duration: started.elapsed(),
356    })
357}
358
359/// Gated spawn shared by one-shot calls and long-lived tool sessions. The caller must
360/// own this future in a supervisor so dropping a request cannot abandon admission.
361pub(crate) async fn spawn_owned<F, R>(
362    command: &Command,
363    input: bool,
364    cancellation: CancellationToken,
365    deadline: Instant,
366    register: F,
367) -> Result<Child, Error>
368where
369    F: FnOnce(ChildIdentity) -> R,
370    R: Future<Output = io::Result<()>>,
371{
372    let program = platform::program::resolve_with(&command.program, &command.cwd, |name| {
373        environment_value(&command.env, name).map(ToOwned::to_owned)
374    })
375    .map_err(Error::Spawn)?;
376    let mut child = tokio::process::Command::new(&program);
377    child
378        .args(&command.args)
379        .current_dir(&command.cwd)
380        .env_clear()
381        .envs(&command.env)
382        .stdin(if input { Stdio::piped() } else { Stdio::null() })
383        .stdout(Stdio::piped())
384        .stderr(Stdio::piped())
385        .kill_on_drop(true);
386    let (gate, mut spawning) = platform::spawn_gated(child).map_err(Error::Spawn)?;
387    let admission = tokio::select! {
388        biased;
389        _ = cancellation.cancelled() => Err(Error::Cancelled),
390        _ = sleep_until(deadline) => Err(Error::Timeout),
391        result = async {
392            let pid = gate.pid().await?;
393            let child = child_identity(pid).map_err(Error::Registration)?;
394            register(child).await.map_err(Error::Registration)?;
395            Ok::<_, Error>(())
396        } => result,
397    };
398    // Cancellation may have arrived during the callback's final poll.
399    let admission = admission.and_then(|()| {
400        if cancellation.is_cancelled() {
401            Err(Error::Cancelled)
402        } else if Instant::now() >= deadline {
403            Err(Error::Timeout)
404        } else {
405            gate.admit().map_err(Error::Io)
406        }
407    });
408    drop(gate);
409    let spawned = crate::task::joined((&mut spawning).await)?;
410    let mut child = match spawned {
411        Ok(child) => child,
412        Err(error) => {
413            return Err(match admission {
414                Err(error @ (Error::Registration(_) | Error::Cancelled | Error::Timeout)) => error,
415                _ => Error::Spawn(error),
416            });
417        }
418    };
419    if let Err(error) = admission {
420        terminate(&mut child).await.map_err(Error::Cleanup)?;
421        return Err(error);
422    }
423
424    Ok(child)
425}
426
427fn environment_value<'a>(
428    environment: &'a BTreeMap<OsString, OsString>,
429    name: &str,
430) -> Option<&'a std::ffi::OsStr> {
431    crate::platform::environment::lookup(environment, name)
432}
433
434/// Intentional desktop handoff: no owned process group, pipes, or kill-on-drop policy.
435pub(crate) fn launch_detached(
436    program: &std::ffi::OsStr,
437    args: &[String],
438    cwd: &std::path::Path,
439) -> Result<(), Error> {
440    let program = platform::program::resolve(program, cwd).map_err(Error::Spawn)?;
441    let mut command = tokio::process::Command::new(program);
442    command
443        .args(args)
444        .current_dir(cwd)
445        .stdin(Stdio::null())
446        .stdout(Stdio::null())
447        .stderr(Stdio::null())
448        .kill_on_drop(false);
449    let mut child = platform::spawn_detached(command).map_err(Error::Spawn)?;
450    tokio::spawn(async move {
451        let _ = child.wait().await;
452    });
453    Ok(())
454}
455
456pub(crate) fn child_identity(pid: u32) -> io::Result<ChildIdentity> {
457    let start_time = platform::process_start_time(pid)?;
458    Ok(ChildIdentity {
459        pid: ProcessId::new(pid),
460        start_time,
461    })
462}
463
464/// Whether `owner` is both present and still the process it was: absent (no local
465/// owner, such as a result mirrored from a remote review store) is never alive.
466pub(crate) fn is_alive(owner: Option<ChildIdentity>) -> io::Result<bool> {
467    let Some(owner) = owner else {
468        return Ok(false);
469    };
470    match platform::running_process_start_time(owner.pid.get()) {
471        Ok(start_time) => Ok(start_time == owner.start_time),
472        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
473        Err(error) => Err(error),
474    }
475}
476
477async fn terminate(child: &mut Child) -> io::Result<()> {
478    let _ = child.interrupt();
479    tokio::select! {
480        _ = sleep(CLEANUP_GRACE) => {},
481        _ = child.wait() => {},
482    }
483    child.cleanup().await?;
484    Ok(())
485}
486
487async fn capture(mut pipe: impl AsyncRead + Unpin, limit: usize) -> io::Result<(Vec<u8>, bool)> {
488    let mut output = Vec::new();
489    let mut buffer = [0; CAPTURE_BUFFER_BYTES];
490    let mut truncated = false;
491    loop {
492        let count = pipe.read(&mut buffer).await?;
493        if count == 0 {
494            return Ok((output, truncated));
495        }
496        let retained = count.min(limit - output.len());
497        output.extend_from_slice(&buffer[..retained]);
498        truncated |= retained < count;
499    }
500}