Skip to main content

bash_interop/rig/
driving.rs

1//! Rust orchestrates: the run starts the subject, exports the session's
2//! address into it, and owns its life.
3
4use std::ffi::{OsStr, OsString};
5use std::fmt;
6use std::io;
7use std::path::Path;
8use std::process::{Child, Command};
9
10use tokio::task::LocalSet;
11
12use super::session::Session;
13use super::watch::Watch;
14use super::{Attended, Kept, Layout, Rig};
15use crate::failure::{Doing, Failure};
16
17/// What a driven run produced.
18///
19/// Reaching one means bash was started and seen out. A `Failure` instead means
20/// the run never got that far: it could not be set up, or a reaction could not
21/// do its work and the subject was killed.
22pub struct Run<K> {
23    /// Every shell that joined, in the order they did.
24    pub shells: Vec<Attended<K>>,
25
26    /// How bash ended — its own, whether or not anything else went wrong.
27    pub subject: ExitStatus,
28
29    /// What went wrong closing up, if anything. After the subject reached its
30    /// own end.
31    pub failed: Option<Failure>,
32}
33
34impl<K> Run<K> {
35    /// The run with its closing-up discharged.
36    pub fn whole(self) -> Result<Whole<K>, Failure> {
37        match self.failed {
38            Some(why) => Err(why),
39            None => Ok(Whole {
40                shells: self.shells,
41                subject: self.subject,
42            }),
43        }
44    }
45}
46
47/// A run that closed cleanly.
48pub struct Whole<K> {
49    pub shells: Vec<Attended<K>>,
50    pub subject: ExitStatus,
51}
52
53/// A rig whose run Rust orchestrates. The impl block is empty: the whole
54/// contract is the two provided entries.
55///
56/// The command line is run as it is given and carries its own program, so a
57/// caller wanting a launcher puts one there: `env TARGET=staging -- bash
58/// x.bash` is the whole story. `environment` is handed the settled [`Layout`]
59/// and its return is the subject's **whole** environment delta — the core
60/// adds nothing. Fallible, because provisioning writes a file:
61/// [`Layout::bash_env`] with a stated [`Provision`](super::Provision) is the
62/// usual pair.
63///
64/// | | |
65/// |---|---|
66/// | what reaches the shells | exactly what `environment(&Layout)` returned |
67/// | where the session is laid | a directory of the run's own ([`run`](Driving::run)), or the caller's ([`run_at`](Driving::run_at)) |
68/// | what ends it | a pidfd on the subject, watched and never signalled; then the group is killed |
69/// | what comes back | [`Run`], and [`Run::whole`] → [`Whole`] |
70///
71/// The future is not `Send`: it runs on a `LocalSet` of its own, and is awaited
72/// from a current-thread runtime or `block_on`.
73#[expect(async_fn_in_trait, reason = "single-threaded by design: no Send bound")]
74pub trait Driving: Rig {
75    /// A workspace of the run's own, gone when the run ends.
76    async fn run<A, E>(&self, argv: &[A], environment: E) -> Result<Run<Kept<Self>>, Failure>
77    where
78        A: AsRef<OsStr>,
79        E: FnOnce(&Layout) -> Result<Vec<(OsString, OsString)>, Failure>,
80        Self: Sized,
81    {
82        driven(self, None, argv, environment).await
83    }
84
85    /// The caller's directory instead — it exists, and is the caller's to
86    /// have made — left behind: a reading taken later may follow source
87    /// paths into it.
88    async fn run_at<A, E>(&self, at: &Path, argv: &[A], environment: E) -> Result<Run<Kept<Self>>, Failure>
89    where
90        A: AsRef<OsStr>,
91        E: FnOnce(&Layout) -> Result<Vec<(OsString, OsString)>, Failure>,
92        Self: Sized,
93    {
94        driven(self, Some(at), argv, environment).await
95    }
96}
97
98/// The one driven orchestration behind both entries.
99async fn driven<R, A, E>(rig: &R, at: Option<&Path>, argv: &[A], environment: E) -> Result<Run<Kept<R>>, Failure>
100where
101    R: Rig,
102    A: AsRef<OsStr>,
103    E: FnOnce(&Layout) -> Result<Vec<(OsString, OsString)>, Failure>,
104{
105    LocalSet::new()
106        .run_until(async {
107            let mut session = Session::open(rig, at)?;
108
109            // The subject lives inside the block: however it leaves, the
110            // group is killed and reaped before the session releases files.
111            let subject = async {
112                let environment = environment(&session.layout)?;
113                let mut subject = Subject::spawn(argv, environment)?;
114
115                session.serve(&Watch::process(subject.pid())?).await?;
116                subject.finish().doing(|| "waiting for bash".into())
117            }
118            .await;
119            let (shells, failed) = session.close().await;
120            let subject = subject?;
121
122            Ok(Run {
123                shells,
124                subject: ExitStatus::from(subject),
125                failed,
126            })
127        })
128        .await
129}
130
131/// The bash the run owns: its process group, and the right to end it.
132struct Subject {
133    child: Child,
134    group: libc::pid_t,
135}
136
137impl Subject {
138    fn spawn<A: AsRef<OsStr>>(argv: &[A], environment: Vec<(OsString, OsString)>) -> Result<Self, Failure> {
139        use std::os::unix::process::CommandExt;
140
141        let said = || {
142            argv.iter()
143                .map(|word| word.as_ref().to_string_lossy())
144                .collect::<Vec<_>>()
145                .join(" ")
146        };
147        let (program, rest) = argv.split_first().ok_or_else(|| {
148            Failure::new(
149                "starting the subject",
150                "the command line is empty",
151            )
152        })?;
153
154        let mut command = Command::new(program);
155        command.args(rest).envs(environment).process_group(0);
156
157        let child = command.spawn().doing(|| format!("spawning {}", said()))?;
158        let group = child.id() as libc::pid_t;
159
160        Ok(Self { child, group })
161    }
162
163    fn pid(&self) -> libc::pid_t {
164        self.group
165    }
166
167    /// Kill the group, then reap — in that order, because while the subject is
168    /// unreaped its group cannot have been recycled.
169    fn finish(&mut self) -> io::Result<std::process::ExitStatus> {
170        self.release();
171        self.child.wait()
172    }
173
174    fn release(&self) {
175        let _ = unsafe { libc::kill(-self.group, libc::SIGKILL) };
176    }
177}
178
179impl Drop for Subject {
180    fn drop(&mut self) {
181        self.release();
182        // `wait`, not `try_wait`: an unreaped child still answers
183        // `kill(pid, 0)`. `Child::wait` caches, so a second call is free.
184        let _ = self.child.wait();
185    }
186}
187
188/// How bash ended. `wait(2)` yields exactly one of these.
189#[derive(Copy, Clone, PartialEq, Eq, Debug)]
190pub enum ExitStatus {
191    Code(u8),
192    Signal(u8),
193}
194
195impl ExitStatus {
196    /// What a shell would report for it: `128 + n` for a signal.
197    pub fn shell_code(self) -> i32 {
198        match self {
199            Self::Code(code) => i32::from(code),
200            Self::Signal(signal) => 128 + i32::from(signal),
201        }
202    }
203}
204
205impl fmt::Display for ExitStatus {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        match self {
208            Self::Code(code) => write!(f, "exit {code}"),
209            Self::Signal(signal) => write!(f, "killed by signal {signal}"),
210        }
211    }
212}
213
214impl From<std::process::ExitStatus> for ExitStatus {
215    /// `WTERMSIG` is the low seven bits, `WEXITSTATUS` the second byte, and
216    /// after `wait(2)` there is no third outcome.
217    fn from(status: std::process::ExitStatus) -> Self {
218        use std::os::unix::process::ExitStatusExt;
219
220        let raw = status.into_raw();
221        match status.signal() {
222            Some(_) => Self::Signal((raw & 0x7f) as u8),
223            None => Self::Code(((raw >> 8) & 0xff) as u8),
224        }
225    }
226}