Design

What src/ is, what each layer knows, and where the shape comes from. The chapter-by-chapter reference is the rest of this book; this document sits above it.

What it is for

Run a bash program, hear every shell in its process tree, and answer the questions those shells ask, while the program behaves as it does when nothing is listening.

That last clause is the hard part. A subject script has its own traps, its own IFS, its own shell options and its own exit status, and a tool that disturbs any of them measures something other than the program. The design is organised around what the instrumentation may not touch, and every capability is built from what remains.

The layers

bash-strings           the quoted forms: @Q, @A, declare -p, Cursor
    └── shell          a shell's account of itself
         ├── stack     bash's five parallel arrays, read back
         └── rig       the session: a workspace, a pipe and a task per shell
knows aboutnever knows about
bash-strings (its own crate)bash's quoted forms — @Q, @A, declare -peverything else
shellbash-stringshow a shell was reached, what it went on to say
stackbash-strings, shellthe wire, the rig, any tool
rigbash-strings, shellthe stack, any tool

stack and rig are siblings, and neither calls the other. A tool composes them: the frame walk goes into the bash a rig injects, through stack::with_walk. bashcap and bashprof are that composition, and a third tool would be the same one with different words.

Both stand on shell, because a walk cannot be read without knowing the shell it was taken in. Bash writes $0 into BASH_SOURCE for code it was given rather than read from a file, and main there for anything defined at an interactive prompt — words a script can also produce. Telling those apart is a property of the shell, and the shell is what knows it.

Messages are arglists

BC_SAY a b c ships three words, and a rig receives three words. Any width, zero included, and the protocol reads no position of one.

Several tools can therefore share one wire. The sender picks its own leading discriminator — TIMETHIS, __BASHCAP__ — and a decoder opts in with line.behind(TAG), receiving None for somebody else's message. There is no registry and nothing to coordinate.

An answer is an arglist too, and the shell that asked runs it as a command in its own frame — bash parses the reply as an array literal and invokes it, with no eval involved. ["__bc_status", "1"] refuses, ["declare", "x=1"] binds a variable in the frame that asked, ["source", path] runs bash of any length the rig wrote to a file, ["exit", "9"] ends the subject, and ["echo", value] hands a value back through a command substitution the script already wrote.

The words run where the call was written, which is what BC_SAY and BC_ASK being aliases buys: an answer's declare binds in the asking function rather than in a frame the protocol opened, so nothing needs -g to be seen, and return there ends the function that asked.

Two consequences follow, and together they are why there is no second protocol here. Bash supplies the expressiveness, so no answer type has to grow. And since every shell sources <dir>/rig.bash on the way in, a reply can call a function the rig defined and pass it arguments computed in Rust, which makes the rig's own bash the vocabulary an answer selects from.

Values travel as bash's own quoted forms

${x[*]@Q} and "(${x[*]@Q})" on the way out, parse_array on the way in; declare -a x="$msg" and emit_array the other way. Both sides speak the notation bash already has, so word boundaries, newlines, tabs and bytes bash cannot display survive without a length prefix, an escape scheme, or a dependency on either side's idea of encoding.

That layer stands on nothing else and is usable on its own; see bash-strings: values.

One coordinate, owned

The workspace directory is the session's address and its only coordinate. Every fifo and file is <dir>/…, modelled in one place by Layout, a validated directory with accessors for the constant names.

The session owns the directory it serves. <dir>/lock is flocked before anything in the directory is touched and held until the fifos are gone. That ownership makes three promises cheap: a second session on the same directory is refused whole, a killed predecessor's leavings are swept at the next open because the kernel released the dead lock, and the join fifo's presence is a truthful liveness signal. A prescribed directory must already exist; making it is the host's job in both roles.

The two laid files

The session lays the generic prelude, shipped verbatim, reading neither its own location nor the environment; and the rig's bash, Rig::bash(&Layout). Both hold definitions only and are inert to source.

Initiation is a line of client code: BC_JOIN <label> <dir> [word…], with zero, one or many labels. The label is client vocabulary, a write-time-stable name the words speak, bound to a run-time coordinate at the join; Rust is never told it. Words after the directory ride the announcement and land on Shell::brought.

A standard initiation line is data the wrapper supplies, and the tools export theirs as a function beside their rig. The core never runs it. It is written into a provisioned startup file, or said by a client's own line. <dir>/bash_env.bash is the one file that may initiate, and Layout::bash_env(provision) is the only thing that writes it: the two sources, then the joining line when Provision::Joining was asked for.

Driven runs

A driven run starts the command line and owns a workspace of its own, either a temporary directory or, with run_at, one the caller made and keeps. Nothing external prescribes or collides with it.

How the shells reach the session is stated at the run. run and run_at take an environment closure — fallible, because provisioning writes a file — and what it returns becomes the subject's entire environment delta. The core exports nothing on its own.

Layout::bash_env(Provision::Joining(…)) is the usual pair, and it joins every non-interactive bash in the tree. This is what makes bashcap run --into out make test work, with every recipe shell make starts joining by itself.

Each tool decides for itself how to carry the workspace in a named variable, and spells that name in its own binary — BASHCAP_INIT "$BASHCAP_SESSION" where a by-hand script says so. --reach is the tools' vocabulary over these spellings: bash-env provisions a joining file, by-hand a definitions file with initiation left to the scripts.

BASH_ENV is a single variable, so two driven runs nested through it shadow each other for the inner subtree. The way around it is a definitions-only provision, which is the tools' --reach by-hand, and the client makes that choice.

The command line is whatever the caller wrote, program included: &["env", "TARGET=staging", "bash", "x.bash"] needs no support from the run.

Served runs

A serving run takes its workspace from outside — --at, existing, with no fallback — and answers to nobody. Nothing is written back, a serving application is a complete standalone program, and the client feeds the same directory to start, probe, load and initiate.

The workspace shows whether anything is live. Its join fifo is present exactly while a session serves, so one file test answers the question. The boundary case is a server killed outright, whose stale fifo stands until its directory is next opened or removed.

Across both roles the join is one line, either a provisioned file's or the client's own, and each tool prints every way a script writes it under --help, in its own words.

A rig describes; a reaction is per shell, and a task

#![allow(unused)]
fn main() {
// abridged — rigs.md quotes the real declarations
trait Rig      { type Reaction: Reacting;  bash(&Layout) -> String;
                 async joined(&Layout, Arc<Shell>) }
trait Reacting { type Kept;  async hear(Message);  async answer(Message) -> Answer;  async finish() -> Kept }
}

Every shell has a pipe of its own, so which shell said something is which pipe it came out of, and every pipe has a task of its own: read a line, react, maybe answer, until end of input.

A shell announces itself with its account — which bash, how it was given its code, where it sits, what it had switched on — on the control fifo, before its pipe is opened, so the run knows all of that before releasing the shell. None of it changes while the shell lives: a subshell gets its own $BASHPID and joins as a shell of its own, and set refuses -i, -c and -s. It is said once, and the reaction built from it holds it as a member from construction. Holding a reaction is therefore proof that its shell announced itself, and a message reaches it only down that shell's own pipe.

The session is single-threaded and concurrent: one current_thread runtime, spawn_local per shell, and no Send bound anywhere. What one shell's reaction awaits — a slow answer, a 100 KB reply, a file opened at joined — holds up that shell alone. Rc<RefCell<_>> is how a share is passed, and the borrow is never held across an .await.

What comes back is one entry per shell, Attended { shell, kept, parted }, where the shape carries the provenance. heard flattens it into the order it was said, by the sending shells' own clocks, when a reading wants the run whole.

Neither trait has a default body, so an implementor decides every case in view. Answer::unknown() names the refusal — return 127, bash's own command not found — and puts it where it applies.

What several shells share, such as a sink or a merged view, belongs to the rig, which hands each reaction a share. The core names no sharing discipline.

Who started the shells is a separate question

Driving runs a command line and owns its process group. Serving lays the session in the workspace the client prescribed and serves while that client holds the handle. Both are traits extending Rig with one provided async fn, so a rig declares which orchestrations it supports by implementing them, and its reaction is the same code either way. A program built on the core exposes the pair as two symmetric verbs; the tools spell them run and serve, with each verb's role table in its own book.

A session lasts as long as anyone who could still speak. Watch is a descriptor — a pidfd, or the handle an initiator holds — and it is only watched. Signalling and reaping belong to whoever started the thing being watched, which is never the session, and that is what lets one session serve both roles. Under Driving the group is killed before the session closes, so every task reads what its shell wrote up to the kill.

Nothing inside a rig ends a session. A rig reacts, and a Failure from it reports that it could not do its work.

The subject keeps everything of its own

no trap installeda client's trap … EXIT fires as it would unwrapped
no builtin shadowedprintf, read, exec mean what they mean
no variable exportednothing leaks into a child that did not join
no name outside BC_*/__BC_*a subject's globals cannot collide with ours
no set -o changeerrexit, nounset, pipefail stay as the subject set them
no evalnothing the subject wrote is re-parsed
its own exit statusa wrapped script is indistinguishable from an unwrapped one

One exception: expand_aliases is turned on and stays on, because the error guards are aliases — return has to act in the frame that failed. IFS is scoped inside two of the protocol's own functions so [*] joins with a space; what the subject had, unset included, is back on return.

The protocol may not use set -e, so every command in it that can fail is followed by || __BC_BAIL or || __BC_THROW. A fault of ours is reported at the subject's call site with status 125, which is what env and timeout return when the wrapper rather than the payload failed, and the script carries on rather than dying mid-message.

What the transport gives every tool

Provenance, ordering, subshell capture, lifetimes and a control channel, none of which a tool implements again.

Every shell has a pipe of its own, made by the shell, announced with its account, and opened by the run. The blocking open is the rendezvous and end of input is the goodbye. A ( … ) or $( … ) that speaks takes a pipe of its own on its first word.

A line is a message. One writer per pipe, so nothing interleaves and no write need be atomic, and the pipe carries no framing. The control fifo has many writers and one reader, so an announcement is sent as frames of at most PIPE_BUF bytes, keyed by the shell's token and reassembled in bytes.

Every message carries both clocks, the sending shell's $EPOCHREALTIME and the run's own. A span is the interval between two of them, which is why nothing is timed in bash, and the sender's clock is what orders a run.

A label belongs to a session in bash, so one process can hold several. Rust is never told it.

The tools are compositions

its bashits reading
bashcapthe walk, plus BASHCAP's effectone JSON object per snapshot, streamed
bashprofthe walk, plus BASHPROF_TIMETHIS's effectthree passes: records, tree, timings

Neither ships a file to a client. The words arrive with the session's own bash, as everything else does. A committed call site makes its tool a dependency of the script that says it: outside a session the word is a missing command, loudly, in the same way an unjoined label reports 125.

Not provided

what stands in its place
a session-wide accumulator in the librarywhat a run produces belongs to the client; Vec<Message> and () are the two shipped
a timer, an interval, a heartbeatserving ends on a descriptor, so tokio's time feature is not enabled
a closing word or reserved payload wordthe handle says when it is over, and nothing in the loop intercepts a message
a way in that the core prefersevery environment comes from the run's closure, every join from a stated line, and --reach is a tool's own vocabulary
a poisoned or degraded modean answer that says no is a command returning non-zero, like any other
parallelisma task per shell on one thread; the cost sits in bash's printf, and a Send bound would tax every implementor
a fork treea fork inherits and then takes its own pipe; its descent is not reported
a schema or IDLan arglist has no shape to agree on

See also