Skip to main content

bash_interop/rig/
mod.rs

1//! Run bash under instrumentation, and hear what it says.
2//!
3//! ```bash
4//! declare -- BC_SAY__ARG_LABEL=DEPLOY
5//! BC_SAY REC compiled "$target"                 # ship an arglist and carry on
6//!
7//! declare -- BC_ASK__ARG_LABEL=DEPLOY           # ask: block, then run the
8//! declare -a BC_ASK__ARGS=(which target)        # answer here, in this frame
9//! BC_ASK
10//! ```
11//!
12//! A [`Rig`] is a **description**: the bash it gives the subject, where the
13//! session's files go, and how to build a reaction once a shell is there. The
14//! reaction is [`Reacting`], and it is made **per shell**, at the moment that
15//! shell announces itself — so which bash it is, how it was started and what
16//! it had switched on are members from construction, never parameters. Every
17//! shell has a pipe of its own and a task of its own, so serving many shells
18//! is many straight-line loops that interleave.
19//!
20//! ```no_run
21//! use std::sync::Arc;
22//! use bash_interop::rig::{
23//!     Answer, Driving, Failure, Layout, Message, Provision, Reacting, Rig, Shell,
24//! };
25//!
26//! /// Keeps what one shell said, and tells it to use staging.
27//! struct Deploying;
28//!
29//! struct Told { shell: Arc<Shell>, heard: Vec<Message> }
30//!
31//! impl Rig for Deploying {
32//!     type Reaction = Told;
33//!
34//!     /// A word the subject's scripts can call, as one command over the
35//!     /// core's, so it composes where any command does. Definitions only:
36//!     /// sourcing this joins nothing.
37//!     fn bash(&self, _at: &Layout) -> String {
38//!         "alias STAGE='BC_SAY__ARG_LABEL=DEPLOY BC_SAY STAGE'\n".to_string()
39//!     }
40//!
41//!     async fn joined(&self, _at: &Layout, shell: Arc<Shell>) -> Result<Told, Failure> {
42//!         Ok(Told { shell, heard: Vec::new() })
43//!     }
44//! }
45//!
46//! /// The standard initiation — data the run's closure hands to `bash_env`;
47//! /// run only where a client or a provisioned file says so.
48//! fn deploy_join(at: &Layout) -> String {
49//!     format!("BC_JOIN DEPLOY {}\n", bash_strings::emit_scalar(at.text()))
50//! }
51//!
52//! impl Reacting for Told {
53//!     type Kept = Self;
54//!
55//!     async fn hear(&mut self, said: Message) -> Result<(), Failure> {
56//!         self.heard.push(said);
57//!         Ok(())
58//!     }
59//!
60//!     async fn answer(&mut self, asked: Message) -> Result<Answer, Failure> {
61//!         Ok(match asked.words.first().map(String::as_str) {
62//!             Some("target") => Answer::of("declare", ["target=staging"]),
63//!             _ => Answer::unknown(),
64//!         })
65//!     }
66//!
67//!     async fn finish(self) -> Result<Self, Failure> { Ok(self) }
68//! }
69//!
70//! impl Driving for Deploying {}
71//!
72//! # #[tokio::main(flavor = "current_thread")]
73//! # async fn main() -> Result<(), Failure> {
74//! // The closure's return is the subject's whole environment. Provisioning
75//! // a joining file is the one auto-initiation there is, and it is stated
76//! // here, at the fringe — every other shell's initiation is its own code.
77//! let ran = Deploying
78//!     .run(&["bash", "deploy.bash"], |at| {
79//!         Ok(vec![at.bash_env(Provision::Joining(&deploy_join(at)))?])
80//!     })
81//!     .await?;
82//! for shell in ran.whole()?.shells {
83//!     println!("pid {} said {} things", shell.shell.pid, shell.kept.heard.len());
84//! }
85//! # Ok(())
86//! # }
87//! ```
88//!
89//! Who started the shells is a second question with exactly two answers, and
90//! each is a trait that carries its own orchestration:
91//!
92//! | | who started the shells | how they find the address | what the session lasts for | what comes back |
93//! |---|---|---|---|---|
94//! | [`Driving`] | the run, in a process group of its own | exactly what the run's environment closure returned — [`Layout::bash_env`] with a stated [`Provision`] the usual pair | that process group | [`Run`], with the subject's [`ExitStatus`] |
95//! | [`Serving`] | a bash script, which named and made the workspace and started the server | its own choice: it feeds the same directory to start, probe, load and initiate | whoever holds the handle | [`Served`] |
96//!
97//! Either way, the address is the workspace directory. Loading its laid
98//! files defines; initiation is the client's own line — except where a
99//! provisioned `bash_env.bash` states [`Provision::Joining`], the one
100//! auto-initiation there is. The book's `docs/joining.md` shows every way
101//! a script joins, each as a whole script.
102//!
103//! **A session lasts as long as anyone who could still speak.** Nothing inside
104//! a rig ends one.
105//!
106//! The session is single-threaded: one `current_thread` runtime, one task per
107//! shell, and no `Send` bound anywhere. What shells share — a sink, a merged
108//! view — is the caller's own, handed in through [`Rig::joined`] as an
109//! `Rc<RefCell<_>>` or whatever it likes; a `RefCell` borrow must not be held
110//! across an `.await`.
111//!
112//! | | |
113//! |---|---|
114//! | `attended` | [`Layout`], [`Attended`], [`Kept`], [`Said`], [`heard`] |
115//! | `session`, `attend` | the conversation: the workspace, the control fifo, one task per shell |
116//! | `watch` | the descriptor a session ends on |
117//! | `driving`, `serving` | the two roles, and what each hands back |
118//! | `wire` | [`Message`], [`Answer`], and the protocol that carries them |
119
120mod attend;
121mod attended;
122mod driving;
123mod serving;
124mod session;
125mod watch;
126pub(crate) mod wire;
127
128use std::sync::Arc;
129
130pub use attended::{Attended, Kept, Layout, Provision, Said, heard};
131pub use driving::{Driving, ExitStatus, Run, Whole};
132pub use serving::{Served, Serving};
133
134pub use wire::{Answer, Message, Micros, Pid, Stamp, Verb, field};
135
136pub use crate::failure::{Doing, Failure};
137pub use crate::shell::Shell;
138
139/// What bash a rig gives the subject, and how a reaction is made once a
140/// shell is there.
141///
142/// A description: `&self` throughout, because nothing about it changes by
143/// running. **No method has a default body.**
144///
145/// | it is handed | it produces |
146/// |---|---|
147/// | [`&Layout`](Layout) — the workspace, and the files in it | [`Self::Reaction`](Rig::Reaction) |
148/// | [`Arc<Shell>`](Shell) — `bash: Bash`, `options: Options`, `brought`, `joined: Stamp` | |
149///
150/// The rig's bash is laid beside the protocol's own by the session;
151/// [`stack::with_walk`](crate::stack::with_walk) composes it where the
152/// rig reports a frame walk.
153// ANCHOR: rig-trait
154#[expect(async_fn_in_trait, reason = "single-threaded by design: no Send bound")]
155pub trait Rig {
156    /// What reacts to one shell.
157    type Reaction: Reacting;
158
159    /// The rig's own bash: **definitions only**. Its words, and at most a
160    /// channel-init function; sourcing it has no effect on a shell beyond
161    /// names coming into being, so it is inert, re-sourceable, and free of
162    /// the coordinate unless its author bakes one in.
163    fn bash(&self, at: &Layout) -> String;
164
165    /// A shell has joined, and everything about it is known. Awaited in the
166    /// accept loop, so a slow `joined` delays the next join and nothing else.
167    async fn joined(&self, at: &Layout, shell: Arc<Shell>) -> Result<Self::Reaction, Failure>;
168}
169// ANCHOR_END: rig-trait
170
171/// One shell's reaction, for as long as that shell can speak.
172///
173/// It runs as a task of its own, so it owns what it holds (`'static`) and is
174/// never sent to another thread. Awaiting inside a method yields to the other
175/// shells' tasks; synchronous work blocks them for its duration.
176///
177/// | | |
178/// |---|---|
179/// | [`hear`](Reacting::hear) | a [`Message`] nobody is waiting on |
180/// | [`answer`](Reacting::answer) | one the shell blocks on; the task writes the [`Answer`] back to it |
181/// | [`finish`](Reacting::finish) | what is left, which lands in [`Attended::kept`] |
182///
183/// **No method has a default body.** The two implementations below are the
184/// templates to copy.
185// ANCHOR: reacting-trait
186#[expect(async_fn_in_trait, reason = "single-threaded by design: no Send bound")]
187pub trait Reacting: Sized + 'static {
188    /// What is left when the shell can no longer speak. `Self` where nothing
189    /// is released at the end.
190    type Kept: 'static;
191
192    /// A `Failure` from this or [`answer`](Reacting::answer) ends the
193    /// conversation: under [`Driving`] the subject is killed and the run
194    /// yields that reason.
195    async fn hear(&mut self, said: Message) -> Result<(), Failure>;
196
197    /// An answer is a command, and every answer is the same kind of thing.
198    /// Saying no is a command that returns non-zero — [`Answer::unknown`] for
199    /// a word this rig has no answer for.
200    async fn answer(&mut self, asked: Message) -> Result<Answer, Failure>;
201
202    /// The conversation is over; release what this held.
203    async fn finish(self) -> Result<Self::Kept, Failure>;
204}
205// ANCHOR_END: reacting-trait
206
207/// A reaction that keeps every message, and has no answer to any of them.
208impl Reacting for Vec<Message> {
209    type Kept = Self;
210
211    async fn hear(&mut self, said: Message) -> Result<(), Failure> {
212        self.push(said);
213
214        Ok(())
215    }
216
217    async fn answer(&mut self, asked: Message) -> Result<Answer, Failure> {
218        self.hear(asked).await?;
219
220        Ok(Answer::unknown())
221    }
222
223    async fn finish(self) -> Result<Self, Failure> {
224        Ok(self)
225    }
226}
227
228/// A reaction that keeps nothing and answers nothing.
229impl Reacting for () {
230    type Kept = Self;
231
232    async fn hear(&mut self, _said: Message) -> Result<(), Failure> {
233        Ok(())
234    }
235
236    async fn answer(&mut self, _asked: Message) -> Result<Answer, Failure> {
237        Ok(Answer::unknown())
238    }
239
240    async fn finish(self) -> Result<Self, Failure> {
241        Ok(())
242    }
243}