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::AsyncWriteExt,
16    time::{Instant, sleep, sleep_until},
17};
18use tokio_util::sync::CancellationToken;
19
20use crate::{
21    platform::{self, Child},
22    tools::human::log::{End, Lines, Run, Stream},
23};
24
25/// Retain bounded stdout/stderr audit per stream while continuing to drain the pipes.
26pub(crate) const OUTPUT_LIMIT: usize = 128 * 1024;
27/// Give interrupted children one second to exit cooperatively before killing/reaping them.
28const CLEANUP_GRACE: Duration = Duration::from_secs(1);
29
30mod capture;
31pub use capture::{CaptureError, StreamFailure};
32#[cfg(test)]
33pub(crate) use capture::{OUTPUT_CLOSE_GRACE, slow_disk};
34
35/// Arguments have already been resolved by the caller. No shell is added.
36#[derive(Debug, Clone)]
37pub struct Command {
38    pub program: OsString,
39    pub args: Vec<OsString>,
40    pub cwd: PathBuf,
41    /// The complete child environment, not additions to the parent's environment.
42    pub env: BTreeMap<OsString, OsString>,
43    pub timeout: Duration,
44}
45
46/// A process id, guaranteed nonzero: every OS reserves 0 for the kernel or an idle
47/// process, never one artifactize spawns or supervises. `#[serde(transparent)]` keeps
48/// the saved and wire representation the same plain integer.
49#[derive(
50    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
51)]
52#[serde(transparent)]
53pub struct ProcessId(std::num::NonZeroU32);
54
55impl ProcessId {
56    /// `pid` is a real, just-observed OS process id, so it is never 0.
57    pub fn new(pid: u32) -> Self {
58        Self(std::num::NonZeroU32::new(pid).expect("a real process id is never 0"))
59    }
60
61    pub fn get(self) -> u32 {
62        self.0.get()
63    }
64}
65
66impl From<std::num::NonZeroU32> for ProcessId {
67    fn from(pid: std::num::NonZeroU32) -> Self {
68        Self(pid)
69    }
70}
71
72impl std::fmt::Display for ProcessId {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        self.0.fmt(f)
75    }
76}
77
78impl rusqlite::types::ToSql for ProcessId {
79    fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
80        Ok(rusqlite::types::ToSqlOutput::from(i64::from(self.0.get())))
81    }
82}
83
84impl rusqlite::types::FromSql for ProcessId {
85    fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
86        let pid = u32::try_from(value.as_i64()?)
87            .map_err(|error| rusqlite::types::FromSqlError::Other(error.into()))?;
88        std::num::NonZeroU32::new(pid).map(Self).ok_or_else(|| {
89            rusqlite::types::FromSqlError::Other(
90                "a saved owner_pid of 0 is not a process id".into(),
91            )
92        })
93    }
94}
95
96/// Process id and start time, captured while the group leader is still inert.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
98pub struct ChildIdentity {
99    pub pid: ProcessId,
100    /// Linux: clock ticks since boot (procfs field 22). macOS: microseconds since the
101    /// epoch (kernel birth timestamp). Windows: 100 ns intervals since 1601.
102    pub start_time: u64,
103}
104
105#[derive(Debug)]
106pub struct Output {
107    pub status: ExitStatus,
108    pub stdout: Vec<u8>,
109    pub stderr: Vec<u8>,
110    pub truncated: bool,
111    pub duration: Duration,
112}
113
114/// Run a program artifactize itself uses, such as git, found through the shared program
115/// lookup, and return its standard output when it succeeds. `None` when it is missing or
116/// fails.
117pub(crate) fn tool_output<'a>(
118    program: &str,
119    args: impl IntoIterator<Item = &'a std::ffi::OsStr>,
120) -> Option<Vec<u8>> {
121    platform::program::tool_output(program, args)
122}
123
124/// Starts a built-in's program, such as `help`'s, through this process layer: in one process
125/// group or Job Object from before it runs, cleaned up with its children, with exactly the
126/// environment given here.
127pub(crate) struct Launcher {
128    pub env: BTreeMap<OsString, OsString>,
129}
130
131impl artifactize_tools::launch::Launcher for Launcher {
132    fn run<'a>(
133        &'a self,
134        launch: artifactize_tools::launch::Launch,
135        cancellation: &'a CancellationToken,
136    ) -> artifactize_tools::launch::Running<'a> {
137        use artifactize_tools::launch::{Finished, LaunchError};
138        Box::pin(async move {
139            let command = Command {
140                program: launch.program.into(),
141                args: launch.args,
142                cwd: launch.cwd,
143                env: self.env.clone(),
144                timeout: launch.timeout,
145            };
146            match run_unregistered(command, None, launch.output_limit, cancellation.clone()).await {
147                Ok(output) => Ok(Finished {
148                    status: output.status,
149                    stdout: output.stdout,
150                    stderr: output.stderr,
151                    truncated: output.truncated,
152                }),
153                Err(Error::Timeout) => Err(LaunchError::TimedOut),
154                Err(Error::Cancelled) => Err(LaunchError::Cancelled),
155                Err(error) => Err(LaunchError::Failed(
156                    error
157                        .argument_refusal()
158                        .unwrap_or_else(|| error.to_string()),
159                )),
160            }
161        })
162    }
163}
164
165#[derive(Debug, Error)]
166pub enum Error {
167    #[error("process could not be spawned: {0}")]
168    Spawn(#[source] io::Error),
169    #[error("child registration failed: {0}")]
170    Registration(#[source] io::Error),
171    #[error("process supervision failed: {0}")]
172    Io(#[from] io::Error),
173    #[error("process cleanup could not be confirmed: {0}")]
174    Cleanup(#[source] io::Error),
175    #[error("process supervision task failed: {0}")]
176    Task(#[from] tokio::task::JoinError),
177    #[error("process timed out")]
178    Timeout,
179    #[error("process was cancelled")]
180    Cancelled,
181    /// The process ended, but its output could not be captured to the end; the error keeps
182    /// how it ended and the output read.
183    #[error(transparent)]
184    Capture(Box<CaptureError>),
185}
186
187impl Error {
188    /// Every ordinary run error is returned only after cleanup or before admission.
189    /// Cleanup/task failures are the exceptions: retain any admission record.
190    pub(crate) fn cleanup_confirmed(&self) -> bool {
191        match self {
192            Self::Cleanup(_) | Self::Task(_) => false,
193            Self::Spawn(_)
194            | Self::Registration(_)
195            | Self::Io(_)
196            | Self::Timeout
197            | Self::Cancelled
198            | Self::Capture(_) => true,
199        }
200    }
201
202    pub(crate) fn argument_refusal(&self) -> Option<String> {
203        match self {
204            Self::Spawn(error) if error.kind() == io::ErrorKind::InvalidInput => Some(format!(
205                "Process arguments could not be passed safely: {error}"
206            )),
207            _ => None,
208        }
209    }
210}
211
212/// Register the inert child before allowing exec. A rejected, cancelled, or timed-out
213/// registration cannot start user code. The callback must not block its executor.
214/// Dropping this future cancels execution; its supervisor still kills and reaps it.
215pub async fn run<F, R>(
216    command: Command,
217    cancellation: CancellationToken,
218    register: F,
219) -> Result<Output, Error>
220where
221    F: FnOnce(ChildIdentity) -> R + Send + 'static,
222    R: Future<Output = io::Result<()>> + Send + 'static,
223{
224    run_with_input(command, None, cancellation, register).await
225}
226
227pub(crate) async fn run_with_input<F, R>(
228    command: Command,
229    input: Option<Vec<u8>>,
230    cancellation: CancellationToken,
231    register: F,
232) -> Result<Output, Error>
233where
234    F: FnOnce(ChildIdentity) -> R + Send + 'static,
235    R: Future<Output = io::Result<()>> + Send + 'static,
236{
237    run_with_input_limit(command, input, OUTPUT_LIMIT, cancellation, register).await
238}
239
240pub(crate) async fn run_with_input_limit<F, R>(
241    command: Command,
242    input: Option<Vec<u8>>,
243    output_limit: usize,
244    cancellation: CancellationToken,
245    register: F,
246) -> Result<Output, Error>
247where
248    F: FnOnce(ChildIdentity) -> R + Send + 'static,
249    R: Future<Output = io::Result<()>> + Send + 'static,
250{
251    let cancellation = cancellation.child_token();
252    let _cancel_on_drop = cancellation.clone().drop_guard();
253    crate::task::joined(
254        tokio::spawn(run_inner(
255            command,
256            input,
257            output_limit,
258            Capture::default(),
259            cancellation,
260            Some(register),
261        ))
262        .await,
263    )?
264}
265
266/// A registration nobody makes: the type of [`run_inner`]'s and [`spawn_owned`]'s absent
267/// `register`.
268pub(crate) type Unregistered = fn(ChildIdentity) -> std::future::Ready<io::Result<()>>;
269
270/// Run a child nobody registers, such as a fingerprint script or a tool's protocol call: it
271/// runs at once, in its own process group or Job Object, and is cleaned up with its children.
272pub(crate) async fn run_unregistered(
273    command: Command,
274    input: Option<Vec<u8>>,
275    output_limit: usize,
276    cancellation: CancellationToken,
277) -> Result<Output, Error> {
278    let cancellation = cancellation.child_token();
279    let _cancel_on_drop = cancellation.clone().drop_guard();
280    crate::task::joined(
281        tokio::spawn(run_inner(
282            command,
283            input,
284            output_limit,
285            Capture::default(),
286            cancellation,
287            None::<Unregistered>,
288        ))
289        .await,
290    )?
291}
292
293/// Where a run's output goes besides its bounded capture: its log files, and the lines a
294/// Tool log shows while it runs.
295#[derive(Default)]
296struct Capture {
297    logs: Option<PathBuf>,
298    lines: Option<(Lines, Lines)>,
299}
300
301/// Run a Human output tool, its output reaching `run`'s Tool log line by line as it arrives
302/// as well as its bounded capture.
303pub(crate) async fn run_watched(
304    command: Command,
305    cancellation: CancellationToken,
306    run: &Run,
307) -> Result<Output, Error> {
308    let cancellation = cancellation.child_token();
309    let _cancel_on_drop = cancellation.clone().drop_guard();
310    let capture = Capture {
311        logs: None,
312        lines: Some((run.lines(Stream::Stdout), run.lines(Stream::Stderr))),
313    };
314    crate::task::joined(
315        tokio::spawn(run_inner(
316            command,
317            None,
318            OUTPUT_LIMIT,
319            capture,
320            cancellation,
321            None::<Unregistered>,
322        ))
323        .await,
324    )?
325}
326
327async fn run_inner<F, R>(
328    command: Command,
329    input: Option<Vec<u8>>,
330    output_limit: usize,
331    Capture { logs, lines }: Capture,
332    cancellation: CancellationToken,
333    register: Option<F>,
334) -> Result<Output, Error>
335where
336    F: FnOnce(ChildIdentity) -> R,
337    R: Future<Output = io::Result<()>>,
338{
339    let started = Instant::now();
340    let deadline = started + command.timeout;
341    if cancellation.is_cancelled() {
342        return Err(Error::Cancelled);
343    }
344    if command.timeout.is_zero() {
345        return Err(Error::Timeout);
346    }
347
348    let mut child = spawn_owned(
349        &command,
350        input.is_some(),
351        cancellation.clone(),
352        deadline,
353        register,
354    )
355    .await?;
356
357    let stdout = child.stdout().take().expect("stdout is piped");
358    let stderr = child.stderr().take().expect("stderr is piped");
359    let stdin = child.stdin().take();
360    // Cancelled once the process group is gone: from then on only a process outside it can
361    // hold the pipes.
362    let gone = CancellationToken::new();
363    let feed = async {
364        if let (Some(mut stdin), Some(input)) = (stdin, input) {
365            tokio::select! {
366                _ = gone.cancelled() => {},
367                _ = cancellation.cancelled() => {},
368                _ = sleep_until(deadline) => {},
369                result = stdin.write_all(&input) => {
370                    // Fingerprint scripts may intentionally ignore their context.
371                    if let Err(error) = result && error.kind() != io::ErrorKind::BrokenPipe {
372                        return Err(Error::Io(error));
373                    }
374                }
375            }
376        }
377        Ok(())
378    };
379    let wait = async {
380        let result = tokio::select! {
381            biased;
382            _ = cancellation.cancelled() => Err(Error::Cancelled),
383            _ = sleep_until(deadline) => Err(Error::Timeout),
384            status = child.wait() => status.map_err(Error::Io),
385        };
386        let cleanup = if result.is_err() {
387            terminate(&mut child).await
388        } else {
389            // An ordinary main-process exit must not leave its descendants running.
390            child.cleanup().await.map(|_| ())
391        };
392        gone.cancel();
393        cleanup.map_err(Error::Cleanup)?;
394        result
395    };
396    let (stdout_lines, stderr_lines) = lines.unzip();
397    let evidence = |name| logs.as_ref().map(|directory| directory.join(name));
398    let streams = async {
399        tokio::join!(
400            capture::capture(
401                stdout,
402                output_limit,
403                evidence("runtime.stdout"),
404                stdout_lines,
405                &gone
406            ),
407            capture::capture(
408                stderr,
409                output_limit,
410                evidence("runtime.stderr"),
411                stderr_lines,
412                &gone
413            )
414        )
415    };
416    let (status, (stdout, stderr), input) = tokio::join!(wait, streams, feed);
417    let status = status?;
418    input?;
419    capture::finish(status, stdout, stderr, started.elapsed())
420}
421
422/// Spawn shared by one-shot calls and long-lived tool sessions. With `register` the child is
423/// held before exec until registered; the caller must own this future in a supervisor so
424/// dropping a request cannot abandon admission. Without it the child runs at once: the hold
425/// would protect nothing, and on Unix it costs a `fork`, which slows with this process's
426/// memory.
427pub(crate) async fn spawn_owned<F, R>(
428    command: &Command,
429    input: bool,
430    cancellation: CancellationToken,
431    deadline: Instant,
432    register: Option<F>,
433) -> Result<Child, Error>
434where
435    F: FnOnce(ChildIdentity) -> R,
436    R: Future<Output = io::Result<()>>,
437{
438    let program = platform::program::resolve_with(&command.program, &command.cwd, |name| {
439        environment_value(&command.env, name).map(ToOwned::to_owned)
440    })
441    .map_err(Error::Spawn)?;
442    let mut child = tokio::process::Command::new(&program);
443    child
444        .args(&command.args)
445        .current_dir(&command.cwd)
446        .env_clear()
447        .envs(&command.env)
448        .stdin(if input { Stdio::piped() } else { Stdio::null() })
449        .stdout(Stdio::piped())
450        .stderr(Stdio::piped())
451        .kill_on_drop(true);
452    let Some(register) = register else {
453        return platform::spawn_now(child).await.map_err(Error::Spawn);
454    };
455    let (gate, mut spawning) = platform::spawn_gated(child).map_err(Error::Spawn)?;
456    let admission = tokio::select! {
457        biased;
458        _ = cancellation.cancelled() => Err(Error::Cancelled),
459        _ = sleep_until(deadline) => Err(Error::Timeout),
460        result = async {
461            let pid = gate.pid().await?;
462            let child = child_identity(pid).map_err(Error::Registration)?;
463            register(child).await.map_err(Error::Registration)?;
464            Ok::<_, Error>(())
465        } => result,
466    };
467    // Cancellation may have arrived during the callback's final poll.
468    let admission = admission.and_then(|()| {
469        if cancellation.is_cancelled() {
470            Err(Error::Cancelled)
471        } else if Instant::now() >= deadline {
472            Err(Error::Timeout)
473        } else {
474            gate.admit().map_err(Error::Io)
475        }
476    });
477    drop(gate);
478    let spawned = crate::task::joined((&mut spawning).await)?;
479    let mut child = match spawned {
480        Ok(child) => child,
481        Err(error) => {
482            return Err(match admission {
483                Err(error @ (Error::Registration(_) | Error::Cancelled | Error::Timeout)) => error,
484                _ => Error::Spawn(error),
485            });
486        }
487    };
488    if let Err(error) = admission {
489        terminate(&mut child).await.map_err(Error::Cleanup)?;
490        return Err(error);
491    }
492
493    Ok(child)
494}
495
496fn environment_value<'a>(
497    environment: &'a BTreeMap<OsString, OsString>,
498    name: &str,
499) -> Option<&'a std::ffi::OsStr> {
500    crate::platform::environment::lookup(environment, name)
501}
502
503/// Intentional desktop handoff: no owned process group, pipes, or kill-on-drop policy.
504pub(crate) fn launch_detached(
505    program: &std::ffi::OsStr,
506    args: &[String],
507    cwd: &std::path::Path,
508) -> Result<(), Error> {
509    let program = platform::program::resolve(program, cwd).map_err(Error::Spawn)?;
510    let mut command = tokio::process::Command::new(program);
511    command
512        .args(args)
513        .current_dir(cwd)
514        .env_clear()
515        .envs(platform::environment::snapshot())
516        .stdin(Stdio::null())
517        .stdout(Stdio::null())
518        .stderr(Stdio::null())
519        .kill_on_drop(false);
520    let mut child = platform::spawn_detached(command).map_err(Error::Spawn)?;
521    tokio::spawn(async move {
522        let _ = child.wait().await;
523    });
524    Ok(())
525}
526
527/// A program that hands part of its work to the desktop, such as a sign-in that opens the
528/// browser, with its standard streams piped to this process and no console window. It and
529/// what it starts are stopped when the child drops, also when its owner dies where the
530/// system allows; [`Child::release`] lets what it started go on once it has ended.
531pub(crate) async fn spawn_handoff(command: &Command) -> Result<Child, Error> {
532    let program = platform::program::resolve_with(&command.program, &command.cwd, |name| {
533        environment_value(&command.env, name).map(ToOwned::to_owned)
534    })
535    .map_err(Error::Spawn)?;
536    let mut child = tokio::process::Command::new(program);
537    child
538        .args(&command.args)
539        .current_dir(&command.cwd)
540        .env_clear()
541        .envs(&command.env)
542        .stdin(Stdio::piped())
543        .stdout(Stdio::piped())
544        .stderr(Stdio::piped())
545        .kill_on_drop(true);
546    platform::spawn_windowless(child)
547        .await
548        .map_err(Error::Spawn)
549}
550
551/// A desktop handoff whose output a Tool log shows: like [`launch_detached`] it is never
552/// owned, killed or waited on by the review, but its stdout and stderr are pipes, drained
553/// line by line into `run` until the program closes them and exits, whoever still listens.
554/// An unwatched run gets the plain handoff. A failed spawn hands the run back to be ended.
555pub(crate) fn launch_watched(
556    program: &std::ffi::OsStr,
557    args: &[String],
558    cwd: &std::path::Path,
559    run: Run,
560) -> Result<(), (Error, Run)> {
561    if !run.watched() {
562        return match launch_detached(program, args, cwd) {
563            Ok(()) => {
564                run.end(End::Launched);
565                Ok(())
566            }
567            Err(error) => Err((error, run)),
568        };
569    }
570    let program = match platform::program::resolve(program, cwd) {
571        Ok(program) => program,
572        Err(error) => return Err((Error::Spawn(error), run)),
573    };
574    let mut command = tokio::process::Command::new(program);
575    command
576        .args(args)
577        .current_dir(cwd)
578        .env_clear()
579        .envs(platform::environment::snapshot())
580        .stdin(Stdio::null())
581        .stdout(Stdio::piped())
582        .stderr(Stdio::piped())
583        .kill_on_drop(false);
584    let mut child = match platform::spawn_watched(command) {
585        Ok(child) => child,
586        Err(error) => return Err((Error::Spawn(error), run)),
587    };
588    let stdout = child.stdout.take().expect("stdout is piped");
589    let stderr = child.stderr.take().expect("stderr is piped");
590    tokio::spawn(async move {
591        // Nothing is retained: the lines go to the log, the rest is discarded. Nobody owns
592        // this program's group, so its pipes are read for as long as anything holds them.
593        let held = CancellationToken::new();
594        let (status, _, _) = tokio::join!(
595            child.wait(),
596            capture::capture(stdout, 0, None, Some(run.lines(Stream::Stdout)), &held),
597            capture::capture(stderr, 0, None, Some(run.lines(Stream::Stderr)), &held),
598        );
599        run.end(match status {
600            Ok(status) => End::exited(&status),
601            Err(error) => End::Failed(error.to_string()),
602        });
603    });
604    Ok(())
605}
606
607/// Resolve the installed CLI, with sibling build outputs for Rust library consumers/tests.
608pub(crate) fn artifactize_executable() -> Result<std::path::PathBuf, String> {
609    let current = std::env::current_exe().map_err(|e| e.to_string())?;
610    for directory in current
611        .parent()
612        .into_iter()
613        .flat_map(|parent| [Some(parent), parent.parent()])
614        .flatten()
615    {
616        if let Some(candidate) = platform::program::candidates(&directory.join("artifactize"))
617            .into_iter()
618            .find(|path| path.is_file())
619        {
620            return Ok(candidate);
621        }
622    }
623    platform::program::resolve(
624        std::ffi::OsStr::new("artifactize"),
625        &std::env::current_dir().map_err(|e| e.to_string())?,
626    )
627    .map_err(|e| e.to_string())
628}
629
630/// The internal supervisor and executor have no inherited requester stdio/lifetime.
631#[derive(Clone, Copy)]
632pub(crate) enum RunEntry {
633    Supervisor,
634    Executor,
635}
636pub(crate) fn spawn_run(
637    program: &std::path::Path,
638    id: &crate::types::RunId,
639    repo: &std::path::Path,
640    state: &std::path::Path,
641    entry: RunEntry,
642) -> Result<(ChildIdentity, tokio::process::Child), Error> {
643    let mut options = platform::private_options();
644    options.write(true);
645    match entry {
646        RunEntry::Supervisor => {
647            options.create_new(true);
648        }
649        RunEntry::Executor => {
650            options.append(true);
651        }
652    }
653    let directory = state.join("runs").join(id.as_str());
654    let stderr = options
655        .open(directory.join("worker.stderr"))
656        .map_err(Error::Spawn)?;
657    let program = platform::program::resolve(program.as_os_str(), repo).map_err(Error::Spawn)?;
658    let mut command = tokio::process::Command::new(program);
659    command
660        .arg("--repo")
661        .arg(repo)
662        .arg("--state-dir")
663        .arg(state)
664        .arg(match entry {
665            RunEntry::Supervisor => "__verify",
666            RunEntry::Executor => "__execute",
667        })
668        .arg(id.as_str())
669        // Internal workers must not pin the repository after publishing a terminal Run.
670        // Authored commands still use their declared repository cwd in `spawn_owned`.
671        .current_dir(directory)
672        .env_clear()
673        .envs(platform::environment::snapshot())
674        .stdin(Stdio::null())
675        .stdout(Stdio::null())
676        .stderr(Stdio::from(stderr))
677        .kill_on_drop(false);
678    let child = platform::spawn_run(command).map_err(Error::Spawn)?;
679    let identity = child_identity(child.id().expect("spawned worker has a PID"))
680        .map_err(Error::Registration)?;
681    Ok((identity, child))
682}
683pub(crate) fn launch_run(
684    program: &std::path::Path,
685    id: &crate::types::RunId,
686    repo: &std::path::Path,
687    state: &std::path::Path,
688) -> Result<ChildIdentity, Error> {
689    let (identity, mut child) = spawn_run(program, id, repo, state, RunEntry::Supervisor)?;
690    tokio::spawn(async move {
691        let _ = child.wait().await;
692    });
693    Ok(identity)
694}
695
696pub(crate) fn child_identity(pid: u32) -> io::Result<ChildIdentity> {
697    let start_time = platform::process_start_time(pid)?;
698    Ok(ChildIdentity {
699        pid: ProcessId::new(pid),
700        start_time,
701    })
702}
703
704/// Whether `owner` is both present and still the process it was: absent (no local
705/// owner, such as a result mirrored from a remote review store) is never alive.
706pub(crate) fn is_alive(owner: Option<ChildIdentity>) -> io::Result<bool> {
707    let Some(owner) = owner else {
708        return Ok(false);
709    };
710    match platform::running_process_start_time(owner.pid.get()) {
711        Ok(start_time) => Ok(start_time == owner.start_time),
712        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
713        Err(error) => Err(error),
714    }
715}
716
717pub(crate) async fn terminate(child: &mut Child) -> io::Result<()> {
718    let _ = child.interrupt();
719    tokio::select! {
720        _ = sleep(CLEANUP_GRACE) => {},
721        _ = child.wait() => {},
722    }
723    child.cleanup().await?;
724    Ok(())
725}
726
727/// Runtime logs live with their Run and are pruned with it. Protocol callers retain
728/// prefix-only capture: changing protocol bytes to head/tail would corrupt replies.
729pub(crate) async fn run_logged<F, R>(
730    command: Command,
731    directory: PathBuf,
732    cancellation: CancellationToken,
733    register: F,
734) -> Result<Output, Error>
735where
736    F: FnOnce(ChildIdentity) -> R + Send + 'static,
737    R: Future<Output = io::Result<()>> + Send + 'static,
738{
739    let cancellation = cancellation.child_token();
740    let _cancel_on_drop = cancellation.clone().drop_guard();
741    crate::task::joined(
742        tokio::spawn(run_inner(
743            command,
744            None,
745            OUTPUT_LIMIT,
746            Capture {
747                logs: Some(directory),
748                lines: None,
749            },
750            cancellation,
751            Some(register),
752        ))
753        .await,
754    )?
755}