bash-interop — the book

Run bash under instrumentation and hear what it says: a session per run, a pipe and a task per shell, words a script speaks and answers it runs. This book is the reference for the design as it stands — what the pieces are, how the two session setups work, and where each responsibility lies.

chapterwhat it covers
overview.mdthe model in one pass, and the vocabulary
design.mdthe design decisions the shape follows from
rigs.mdRig, Reacting, Layout, Provision — the API
driving.mdRust orchestrates: run, run_at, the environment closure
serving.mdbash orchestrates: serve, the coprocess convention
joining.mdevery way a shell joins, and who initiates
wire.mdthe protocol: files, fifos, frames, messages
shell.mda shell's account of itself
stack.mdthe frame walk, both halves
scoping.mdwhere names bind in the shipped bash
measurements.mdthe kernel and bash facts the transport stands on

Rust blocks quoting the tree are anchored. A fence preceded by an HTML comment declaring quote: <file> anchor=<name> holds the // ANCHOR: region of that file, kept identical by sync-quotes.bash and checked in CI; client-usage examples live compiled in tests/book.rs.

Bash blocks are hand copies, because a marker in shipped bash would ride into every laid workspace file, so touching one side of a bash block means checking the other. The complete client scripts also live as fixtures in bashprof/__fixtures/book/, where that crate's cli suite runs them as printed.

Overview

You have a bash program — a build script, a make test run, a deploy — and you want to hear what happens inside it while it runs: which functions ran, what a variable held at some moment, how long a step took. You want the program to behave the same while you listen, and you may want to answer it, letting the running script ask a question and act on the reply.

This chapter walks the whole model once. Every later chapter is a close-up of one part.

The two sides

There are two sides, and they meet in the filesystem.

The bash side is the program under instrumentation, called the subject, and every bash process in its tree that takes part. A process that takes part is a shell, and processes count separately: a subshell ( … ), a command substitution $( … ), a bash -c, a child script — each is its own process with its own state, so each is its own shell.

The Rust side is the session. It owns a directory, the workspace, listens there, and runs one small event loop. For every shell that joins it builds a reaction, which is your code, and from then on that shell and that reaction talk over a pipe of their own.

Nothing else connects the two. There is no daemon, no socket, no environment protocol. A shell finds a session because somebody told it the workspace directory, and everything after that happens in files under that directory.

What a shell says, and what it can be told

Once joined, a script has two words. Each names the channel it speaks on in a variable beside the call:

declare -- BC_SAY__ARG_LABEL=DEPLOY
BC_SAY REC compiled "$target"                 # ship these words; carry on

declare -- BC_ASK__ARG_LABEL=DEPLOY
declare -a BC_ASK__ARGS=(which-target)
BC_ASK                                        # ship, block, run the reply

BC_SAY ships the words as one message and the script continues immediately, since nothing is waiting on it. BC_ASK blocks until your reaction replies.

Both are aliases, which is what puts the reply in the frame that asked. An alias's trailing words land on the last command of its expansion; for BC_SAY that is the message, so its words ride on the right, and for BC_ASK that is the answer, so its payload goes in BC_ASK__ARGS instead. A rig usually gives scripts a word of its own over these, and then a call site is one line:

alias STAGE='BC_SAY__ARG_LABEL=DEPLOY BC_SAY STAGE'

A message carries an arglist — the words exactly as the caller wrote them, any number of them, boundaries preserved — plus the verb and two clocks, the shell's own $EPOCHREALTIME and the session's clock at the read. There is no schema. The first word is whatever convention your rig and your scripts agree on, which lets several tools share one session without coordinating.

DEPLOY above is the label. It is bash-side vocabulary: a lookup key binding a name your scripts use to the workspace they joined, so one process can hold several sessions at once. The Rust side is never told the label. It sees which pipe a message came out of.

The reply is a command

What comes back from an ask is also a list of words, and the asking shell parses it with bash's own array syntax and then invokes it, in the frame that asked:

declare -ga __BC__ANSWER="$__bc_line"   # the reply, read as a bash array literal
"${__BC__ANSWER[@]}"                    # and invoked, right where you asked

No eval takes part. Bash reads an array literal — the notation declare -p prints — and calls the result.

Replying with a command rather than a value is where the generality comes from, because running one command in the caller's frame already spans what a richer protocol would need types for:

the replywhat the asking shell does
["echo", "/usr/lib"]prints it, so x=$(BC_ASK) captures a value
["declare", "target=staging"]binds a variable in the frame that asked
["__bc_status", "3"]gives the ask status 3, so if BC_ASK branches on the reply
["return", "3"]returns 3 from the function that asked, ending it
["source", "/tmp/x.bash"]runs a file of any length the rig just wrote
["exit", "9"]ends the subject

The ask exits with the status of whatever ran, so a reply that says no arrives as an ordinary shell failure the script can test.

Because the reply runs where the call was written, declare binds in the asking function and dies with it, and local works there too — nothing has to reach for -g to be seen.

The command need not be a builtin. <dir>/rig.bash holds bash your rig wrote, and every shell sources it on the way in, so a reply may call a function you defined there and pass it arguments your Rust code computed. It has to be a function: the reply runs as "${__BC__ANSWER[@]}", and that expansion names commands rather than aliases. The rig supplies the vocabulary; the reply picks a word from it. That is the whole control channel, and it needs no eval, no reserved words and no second protocol.

Joining: definitions, then initiation

A shell comes to be joined in two steps.

Loading brings the definitions in. source <dir>/prelude.bash defines the protocol's words, BC_JOIN, BC_SAY and BC_ASK; source <dir>/rig.bash defines the words your rig adds. The session lays both files, and both are inert — sourcing them defines functions and changes nothing else.

Initiation opens the channel. One line, BC_JOIN LABEL <dir>, usually wrapped in an init function the rig defined. At this line the shell announces itself and gets its pipe.

Client code says that line, at a place it chooses. The one exception is that a run may provision a startup file, <dir>/bash_env.bash, pointed to by BASH_ENV, and declare whether that file initiates or only defines. This is how a driven run reaches programs that have never heard of the session: bash sources BASH_ENV in every non-interactive shell as it starts, so the whole process tree joins without cooperating. joining.md gives every way in, each as a complete script.

The workspace

After a session opens, its directory looks like this:

<dir>/
├── lock            flock()ed by the session for its whole life
├── prelude.bash    laid: the protocol's words       (definitions only)
├── rig.bash        laid: your rig's words           (definitions only)
├── bash_env.bash   provisioned on request: the startup file for BASH_ENV
│
├── join            fifo: every shell announces itself here, once
├── up.<token>      fifo, one per shell: its messages, one per line
└── rep.<token>     fifo, one per shell: answers to its asks

The directory is the session's address, the one coordinate anybody needs, and the session owns it. The lock is taken with flock before anything else is touched and released after the fifos are gone.

Three consequences follow. A second session on the same directory is refused rather than corrupting the first. A session killed outright leaves its fifos behind, and the next open sweeps them safely, because the kernel released the dead session's lock. And the join fifo exists exactly while a session serves, so [[ -p <dir>/join ]] answers whether one is up.

One shell, start to finish

 the shell (bash)                              the session (Rust)
 ───────────────                               ─────────────────
 sources prelude.bash, rig.bash                waits on the join fifo
 BC_JOIN LABEL <dir>
   1. writes its announcement ──── join ────►  reads the announcement,
      (its account: which bash,                builds Shell from it,
       how started, options,                   awaits your Rig::joined
       the words the join brought)             → your Reaction exists
   2. blocks opening up.<token> ◄─ open ─────  opens the pipe: the shell
      …unblocked: it is joined                 is admitted; a task starts

 BC_SAY words…          ───────── up.<token> ► task reads a line
                                               → your hear(message)
 BC_ASK                 ───────── up.<token> ► → your answer(message)
   blocks reading rep    ◄─────── rep.<token>  writes the answer command
   runs the answer; the ask exits with it

 exits (or just stops talking)                 pipe reaches end of input
                                               → your finish() runs
                                               → Attended { shell, kept }

The blocking open in step 2 is a rendezvous. The shell cannot proceed until the session has its pipe open, so a shell that says one thing and exits within microseconds still gets heard.

The account travels with the announcement. By the time your reaction is built, what is knowable about the shell — which bash, how it was invoked, what options it had on, the extra words its join carried — is in your hands as Shell, and none of it changes while the shell lives.

Each shell has its own pipe and its own task. Which shell said something is which pipe it arrived on, and a slow reaction delays only its own shell.

How it ends

A session lasts as long as anyone who could still speak. What it watches is a file descriptor: under a driven run a pidfd on the subject, under a served one a handle the initiating script holds. The session only observes it. When the watch fires, a driven run kills the process group it started and reaps it; a served run kills nothing, having started nothing. Then the session closes. Every task reads what its pipe still holds, every reaction finishes, the fifos are removed, and the lock is released last.

Nothing inside a rig ends a session. A Failure from your code reports that your code could not do its work, and the session still closes cleanly.

What the subject keeps

The shipped bash installs no trap, shadows no builtin, exports no variable, takes no name outside BC_* and __BC_*, changes no set -o option, never uses eval, and leaves the subject's exit status alone. It turns expand_aliases on, because its error guards are aliases: return has to act in the frame that failed. Each claim has a wire-level proof behind it, listed one by one in measurements.md.

Vocabulary

termwhat it names
subjectthe bash program under instrumentation: the command line a driven run starts, or the script that started a server
shellone bash process that joined; Shell
sessionone run: a workspace, a control fifo, a pipe and a task per shell, until the watch fires
workspacethe session's directory and address, locked for its life; modelled by Layout
labelthe bash-side key binding a name to a joined workspace; Rust never sees it
rigyour description: definitions, and how a reaction is built; Rig
reactionyour per-shell counterpart, run as a task of its own; Reacting
message / answerone arglist a shell shipped / one command a blocked shell runs; Message, Answer
accountwhat a shell says of itself when announcing; becomes Shell
keptwhat a reaction leaves behind; Reacting::Kept, landing in Attended::kept
driving / servingwho started the shells: Rust owns a command line, or a bash script holds the handle; Driving, Serving
provisionwhat a bash_env.bash does about the channel: joins, or only defines; Provision
watchthe descriptor a session ends on; observed, never signalled

The two tools, and reading on

bashcap, a full shell snapshot at every call site, and bashprof, a timed call tree, are built from this crate's public surface. Each is a rig plus a reading, each with its own book in its own repository, and a third tool would be the same composition with different words.

From here: design.md states the decisions this shape follows from. rigs.md is the API you implement. driving.md and serving.md are the two orchestrations, joining.md every way in, and wire.md the protocol underneath. The full Rust surface is in rustdoc: cargo doc --no-deps --open.

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

The rig

This chapter is the API: the two traits you write, Rig and Reacting, the values you are handed, Layout and Shell, and what a finished run gives back. It ends with the session machinery underneath, so the guarantees above it can be checked rather than assumed.

Where the code lives, for reading along:

src/rig/
      mod.rs       `Rig`, `Reacting`, the two shipped reactions
      attended.rs  `Layout`, `Provision`, `Attended`, `Kept`, `Said`, `heard`
      driving.rs   `Driving`, `Run`, `Whole`, `ExitStatus`
      serving.rs   `Serving`, `Served`
      session.rs   `Session` — open, serve, announced, close
      attend.rs    `attend` — one shell's task, start to finish
      watch.rs     `Watch` — what a session ends on
      wire/        the protocol (its own chapter: wire.md)

A rig at a glance

A rig that gives the subject one word, keeps what each shell says, and answers one question. This is a compressed sketch; the compiled original, kept honest by the doctest gate, is the module doc of rig.

#![allow(unused)]
fn main() {
struct Deploying;                                        // the description
struct Told { shell: Arc<Shell>, heard: Vec<Message> }   // one shell's reaction

impl Rig for Deploying {
    type Reaction = Told;

    // definitions only: a word scripts can call; nothing joins here
    fn bash(&self, _at: &Layout) -> String {
        "alias STAGE='BC_SAY__ARG_LABEL=DEPLOY BC_SAY STAGE'\n".to_string()
    }

    // a shell joined: build its reaction from what it said of itself
    async fn joined(&self, _at: &Layout, shell: Arc<Shell>) -> Result<Told, Failure> {
        Ok(Told { shell, heard: Vec::new() })
    }
}

impl Reacting for Told {
    type Kept = Self;
    async fn hear(&mut self, said: Message) -> Result<(), Failure> {
        self.heard.push(said);
        Ok(())
    }
    async fn answer(&mut self, asked: Message) -> Result<Answer, Failure> {
        Ok(match asked.words.first().map(String::as_str) {
            Some("target") => Answer::of("declare", ["-g", "target=staging"]),
            _ => Answer::unknown(),
        })
    }
    async fn finish(self) -> Result<Self, Failure> { Ok(self) }
}

impl Driving for Deploying {}     // opt in to the Rust-orchestrated role

// the standard initiation, as data — the run's closure hands it to
// bash_env; run only where a client or a provisioned file says so
fn deploy_join(at: &Layout) -> String {
    format!("BC_JOIN DEPLOY {}\n", bash_strings::emit_scalar(at.text()))
}
}

Three shapes make up the arrangement. The rig is a single value describing all of it. The reaction is a second type, built fresh for each shell. The roles — Driving here — are empty impls you opt into, and the trait brings the orchestration with it.

Rig

As it stands in src/rig/mod.rs; the doc comments are the contract.

#![allow(unused)]
fn main() {
#[expect(async_fn_in_trait, reason = "single-threaded by design: no Send bound")]
pub trait Rig {
    /// What reacts to one shell.
    type Reaction: Reacting;

    /// The rig's own bash: **definitions only**. Its words, and at most a
    /// channel-init function; sourcing it has no effect on a shell beyond
    /// names coming into being, so it is inert, re-sourceable, and free of
    /// the coordinate unless its author bakes one in.
    fn bash(&self, at: &Layout) -> String;

    /// A shell has joined, and everything about it is known. Awaited in the
    /// accept loop, so a slow `joined` delays the next join and nothing else.
    async fn joined(&self, at: &Layout, shell: Arc<Shell>) -> Result<Self::Reaction, Failure>;
}
}

bash() takes &Layout because baking the coordinate in is a freedom. Most rigs ignore the parameter and return the same bytes for every session; a rig that wants the workspace inside a definition can have it. The text becomes <dir>/rig.bash, laid by the session, and sourcing that file is safe because this method promises definitions only.

The initiation line lives with the wrapper, the code that owns the run and its environment closure. A provisioned bash_env.bash is the one place allowed to automate initiation, and it takes the line as plain data, Provision::Joining(&line). The tools each export theirs as a function beside their rig, such as bashprof::joining(at), and a by-hand script types the same line. The core takes a string and has no method for it, so the wrapper states which line initiates a rig, at the point where the run is made.

joined() is async because it runs in the session's accept loop, between a shell announcing itself and its pipe opening, where you may need to open a file, allocate a resource, or consult something. A slow joined delays the next join and never a shell already admitted.

&self throughout, because a rig is a description and running it changes nothing about it. Everything that changes lives in the reactions.

Reacting

#![allow(unused)]
fn main() {
#[expect(async_fn_in_trait, reason = "single-threaded by design: no Send bound")]
pub trait Reacting: Sized + 'static {
    /// What is left when the shell can no longer speak. `Self` where nothing
    /// is released at the end.
    type Kept: 'static;

    /// A `Failure` from this or [`answer`](Reacting::answer) ends the
    /// conversation: under [`Driving`] the subject is killed and the run
    /// yields that reason.
    async fn hear(&mut self, said: Message) -> Result<(), Failure>;

    /// An answer is a command, and every answer is the same kind of thing.
    /// Saying no is a command that returns non-zero — [`Answer::unknown`] for
    /// a word this rig has no answer for.
    async fn answer(&mut self, asked: Message) -> Result<Answer, Failure>;

    /// The conversation is over; release what this held.
    async fn finish(self) -> Result<Self::Kept, Failure>;
}
}

hear receives a say, where nobody is waiting, so there is nothing to produce but success or a Failure. answer receives an ask, where a shell is blocked, and the Answer you return is written back as the command that shell runs. finish consumes the reaction when its shell can no longer speak, and what it returns becomes the shell's entry in the run's result.

&mut self because the reaction is what changes. Message arrives by value because it is yours from then on. 'static because each reaction runs as a task of its own and must own what it holds.

No method has a default body, so an implementor decides every case in view. The two common whole behaviours ship as types instead: name one as your Reaction and write nothing.

shipped reactionhearanswerfinish
Vec<Message>pushhear it, then Answer::unknown()Ok(self)
()drop itAnswer::unknown()Ok(())

Answer::unknown() gives the ask status 127, bash's own command-not-found, so a script asking a question no rig answers sees an ordinary, testable failure. It goes through the prelude's __bc_status rather than bash's return, because the answer runs in the frame that asked and a bare return there would end the function holding the call site — which Answer::returning is for.

Sharing between shells

Several shells writing into one place — an output file, a merged view — share a resource the rig owns, and joined hands each reaction a share. From bashcap:

#![allow(unused)]
fn main() {
type Sink = Rc<RefCell<BufWriter<File>>>;

struct BashCap   { into: PathBuf, sink: Sink, tracing: Tracing }   // the rig owns it
struct Capturing { shell: Arc<Shell>, into: PathBuf, sink: Sink, written: usize }
}

Rc<RefCell<_>> fits because the session is single-threaded: one current_thread runtime, one spawn_local task per shell, no Send bound anywhere. The rule async adds is to never hold the RefCell borrow across an .await; the borrow itself is fine, and the panic case is another task borrowing while yours is parked. Every reaction in this crate borrows, writes and returns without awaiting.

Awaiting inside hear, answer or finish yields to the other shells' tasks, and synchronous work blocks them for its duration, on the usual terms of a cooperative loop.

Facts are members, not parameters

Which bash a shell is, how it was started, what options it had on, and which words its join brought are settled before its first message and cannot change while it lives. A subshell that differs is a new shell with its own $BASHPID, and set refuses -i, -c and -s. So it arrives once, at joined, as Arc<Shell>, and a reaction that needs it keeps it as a member:

#![allow(unused)]
fn main() {
struct Seen { shell: Arc<Shell>, captures: Vec<Capture> }

async fn joined(&self, _at: &Layout, shell: Arc<Shell>) -> Result<Seen, Failure> {
    Ok(Seen { shell, captures: Vec::new() })
}
}

Owning a reaction is the evidence that its shell announced itself, and a message reaches a reaction only down that shell's own pipe, so no path through the session holds a message whose shell is unknown.

Layout and Provision

joined, bash and the environment closure all receive &Layout. It is the workspace: one validated coordinate, held as text because it crosses into bash, plus the model of the files inside. The constant names — prelude.bash, rig.bash, bash_env.bash, join, up.<tok>, rep.<tok>, lock — exist nowhere else in the codebase.

What you call on it:

  • at.text() — the directory as text, ready for bash_strings::emit_scalar when a joining line spells it;
  • at.path() — the same as a &Path, for Rust's own file work;
  • at.bash_env(provision) — the one owner of the provisioned startup file: it writes the file and yields the ("BASH_ENV", <file>) pair for the environment closure to return.

The last one takes the choice its caller states first:

#![allow(unused)]
fn main() {
/// What the provisioned file does about the channel — the first thing a
/// [`Layout::bash_env`] caller states.
#[derive(Copy, Clone, Debug)]
pub enum Provision<'a> {
    /// The file ends with this line — supplied by the provisioner, usually
    /// the rig's standard initiation: subjects with no prior knowledge join
    /// as their shells start.
    Joining(&'a str),

    /// Definitions only: the client code initiates its own channel, and the
    /// file carries no coordinate — the caller states one beside this pair
    /// if its scripts need it.
    Definitions,
}
}

The two arms carry different information, which is why this is an enum. Joining needs the line to write, the wrapper's own. Definitions leaves the file without a coordinate, so a caller whose scripts must find the workspace states a variable for it beside this pair; the tools spell theirs BASHPROF_SESSION and BASHCAP_SESSION. joining.md shows both arms as whole scripts, including what happens to a shell that has the words and never initiates.

Behind the same type sits the ownership story, told once here and assumed elsewhere. The session flocks <dir>/lock before touching anything and releases it after its fifos are gone. An occupied workspace is refused whole. A predecessor killed outright leaves stale fifos that the next open sweeps safely, the kernel having released the dead lock. The join fifo therefore exists exactly while a session serves, which is what makes [[ -p <dir>/join ]] a truthful liveness probe.

What a run hands back

One entry per shell, in join order:

#![allow(unused)]
fn main() {
pub struct Attended<K> {
    pub shell: Arc<Shell>,
    pub kept: K,
    /// When nobody could write on its pipe any more. `None` for a shell the
    /// session outlived.
    pub parted: Option<Micros>,
}

pub type Kept<R> = <<R as Rig>::Reaction as Reacting>::Kept;
}

Which shell produced something is which entry you are holding, so there is no field to match up. parted is an Option because it records a genuine either/or: Some(when) for a shell that finished while the session watched, None for a shell still alive when the session ended, such as a served client outliving the handle.

When a reading wants the run flat again — every message from every shell, in the order things were said:

#![allow(unused)]
fn main() {
pub struct Said<'a> { pub shell: &'a Arc<Shell>, pub message: &'a Message }

pub fn heard<K: AsRef<[Message]>>(shells: &[Attended<K>]) -> Vec<Said<'_>>;
}

Separate pipes have no arrival order between them, so heard sorts by Stamp::sent_at, the sending shells' own clocks, stably over join order. Your own Kept joins in by implementing AsRef<[Message]>. The core ships no session-wide collector, since what needs to be whole across shells is a resource the rig owns, in the Sink pattern above.

Under the floor: the session

Nothing below is API. Seeing the loop once makes the guarantees above concrete.

Both orchestrations drive the same Session, sketched from src/rig/session.rs; rustdoc is authoritative.

#![allow(unused)]
fn main() {
struct Session<'r, R: Rig> {
    rig: &'r R,
    layout: Layout,
    control: Control,          // the join fifo, held open read-write
    attending: JoinSet<…>,     // one task per admitted shell
    closing: watch::Sender<bool>,
    joined: usize,             // the next shell's `nth`
    done: Vec<Attended<…>>,
    _lock: Lock,               // released last; the kernel's on any death
    _temporary: Option<TempDir>,
}
}

The serve loop is three arms:

#![allow(unused)]
fn main() {
loop {
    tokio::select! {
        biased;
        announced = self.control.next()         => self.announced(announced?).await?,
        Some(done) = self.attending.join_next() => …,   // a task's Failure ends the run here
        fired = watch.fired()                   => return fired,
    }
}
}

In the first arm a shell announced itself on the join fifo. Its account — which bash, how invoked, what options, the join's extra words — arrived with the announcement, reassembled from frames, which wire.md explains. announced makes the shell's reply fifo, opens its pipe, builds Shell, awaits your joined, and spawns the task. Opening the pipe is what releases the shell from its blocking rendezvous. Nothing in this loop awaits an admitted shell.

In the second arm some shell's task finished. Its Attended is collected, and a Failure it carried ends the run here.

In the third the watch fired, because the subject exited under driving or the handle was released under serving. The loop returns, and close does the one ending there is: signal every task to drain what its pipe still holds, finish every reaction, release any shell announced but not admitted, remove the fifos, unlink join last, and hand back (Vec<Attended<…>>, Option<Failure>).

When a rig fails

A Failure from joined, hear or answer reports that the operator broke, not the subject.

happeningwhose problem, and what follows
a rig cannot hear, or cannot decide an answerthe run's: under Driving the subject's process group is killed, and the run returns that Failure
an answer that returns non-zerothe subject's, entirely ordinary: set -e, ||, or ignoring it are its own choices
a line on a pipe the protocol did not write, or one left half-writtenthe run's while serving; reported in failed if found at close

The subject is not told when the operator breaks. An answer is a command, and no command arriving means the asking shell blocks until the kill or the close reaches it. In both roles every path after Session::open sees the session out: whatever failed is held while close runs, then returned.

Two small types complete the error picture:

#![allow(unused)]
fn main() {
pub enum ExitStatus { Code(u8), Signal(u8) }   // shell_code(): 128 + n for a signal
}

How the run went and how the subject ended are different facts. Run::failed carries the first and ExitStatus the second, and no signal disposition is changed. Then the crate's one error:

#![allow(unused)]
fn main() {
pub struct Failure { doing: String, cause: Box<dyn Error + Send + Sync> }

pub trait Doing<T> {
    fn doing(self, what: impl FnOnce() -> String) -> Result<T, Failure>;
}
}

A context and a cause rather than an enum, because every consumer either displays it or walks source().

See also

  • driving.md / serving.md — the two roles' own chapters
  • wire.md — the fifos, the lines, and what an answer is on the wire
  • shell.md — everything Shell knows and how it knows it
  • stack.md — the frame walk any instrument can reuse
  • bashcap's book — a real rig that streams instead of keeping

Driving

Under the driven role your Rust program is in charge. It has a rig and a command line to run — a build, a test suite, one script — and wants the whole thing carried out under instrumentation with the results back as values. The run starts the subject, owns it, and sees it out.

Using it is one call:

#![allow(unused)]
fn main() {
let ran = Deploying
    .run(&["bash", "deploy.bash"], |at| {
        Ok(vec![at.bash_env(Provision::Joining(&deploy_join(at)))?])
    })
    .await?;
}

The two entry points, abridged; rustdoc is authoritative:

#![allow(unused)]
fn main() {
pub trait Driving: Rig {
    /// A workspace of the run's own, gone when the run ends.
    async fn run(&self, argv, environment) -> Result<Run<Kept<Self>>, Failure>;

    /// The caller's directory instead — it exists, and is the caller's to
    /// have made — left behind: a reading taken later may follow source
    /// paths into it.
    async fn run_at(&self, at: &Path, argv, environment) -> Result<Run<Kept<Self>>, Failure>;
}
}

They differ in where the workspace lives. run opens a temporary directory that vanishes with the run. run_at uses a directory you made, refusing when it is missing, and leaves it behind, which matters when a reading taken later follows source paths into it. Opting a rig in is an empty impl block, impl Driving for Deploying {}, since the orchestration is provided.

The command line

argv runs exactly as given and carries its own program: &["bash", "deploy.bash"], &["make", "test"]. There is no hidden shell and no argument rewriting, so a launcher goes in the same way anything else does — &["env", "TARGET=staging", "bash", "deploy.bash"].

The environment closure

The second argument settles what the subject's environment gets. The core adds nothing on its own, so whatever the closure returns becomes the subject's entire environment delta. It receives the settled Layout, with the workspace made and the files laid, and it is fallible, because provisioning writes a file.

Three closures cover the usual cases.

#![allow(unused)]
fn main() {
// Blanket: provision a joining startup file. Every non-interactive
// bash in the subject's tree joins as it starts — the right default
// for subjects that know nothing of the session. The line is the
// wrapper's own statement (rigs.md: the sketch's deploy_join).
|at| {
    Ok(vec![at.bash_env(
        Provision::Joining(&deploy_join(at)),
    )?])
},
}
#![allow(unused)]
fn main() {
// Chosen: provision definitions only, and hand the coordinate to
// the scripts under a name of YOUR convention — they initiate where
// they say. (bashprof spells this BASHPROF_SESSION, bashcap
// BASHCAP_SESSION.)
|at| {
    Ok(vec![
        at.bash_env(Provision::Definitions)?,
        (
            "DEPLOY_SESSION".into(),
            at.text().into(),
        ),
    ])
},
}
#![allow(unused)]
fn main() {
// Nothing: the subject runs with no additions at all. Shells can
// still join by hand if some script knows the workspace by other
// means.
|_at| Ok(vec![]),
}

Further variables of your own ride along in the same vector. tests/proofs/starting.rs starts a subject and shows that a variable the closure did not return is absent from it.

What comes back

#![allow(unused)]
fn main() {
pub struct Run<K>   { pub shells: Vec<Attended<K>>, pub subject: ExitStatus, pub failed: Option<Failure> }
pub struct Whole<K> { pub shells: Vec<Attended<K>>, pub subject: ExitStatus }   // via Run::whole()
}

Reaching a Run means bash was started and seen out, and subject is the subject's own exit status, untouched. failed holds anything that went wrong while closing up, such as a half-written line found at the end or a reaction that would not finish. They are separate facts: a subject may exit 0 with a damaged reading, or exit 9 with a sound one.

Run::whole() is the discharge point. It returns Whole, with failed converted into an Err, so code that wants only a clean run writes .run(…).await?.whole()? and holds a type that cannot carry an undischarged problem. A Failure in place of a Run means the run never got that far, as when the workspace was occupied or the spawn failed.

The subject's life

The run spawns the subject with process_group(0), giving it a group of its own, and watches its pidfd without installing a signal handler.

When the subject exits, the watch fires and the run kills the group and then reaps, in that order. While the subject is unreaped its group id cannot have been recycled, so the kill cannot reach a stranger. The group kill is what ends stragglers, since a background process the subject left behind would otherwise hold pipes open indefinitely.

Only after that does the session close, so every task reads what its shell wrote up to the kill and sees a clean end of input. If run leaves by another path, whether a reaction's Failure or a panic unwinding, Drop on the subject does the same kill and reap.

Serving

Under the served role the program is already running, and it starts the session, usually by starting a tool's serve verb as a coprocess. This is the mode for a script that instruments itself: it decides when the session begins, does its work, and collects the reading when it is done. The Rust side starts nothing, kills nothing, and writes nothing back to the client.

The surface, abridged; rustdoc is authoritative:

#![allow(unused)]
fn main() {
pub trait Serving: Rig {
    async fn serve(&self, at: &Path, held: OwnedFd) -> Result<Served<Kept<Self>>, Failure>;

    /// serve, with the handle being this process's own standard input —
    /// the coprocess convention's server half.
    async fn serve_coprocess(&self, at: &Path) -> Result<Served<Kept<Self>>, Failure>;
}

pub struct Served<K> { pub shells: Vec<Attended<K>>, pub failed: Option<Failure> }
}

Two parameters carry the contract.

at is the workspace, which the client names and makes. It is required, has no fallback, and must already exist, so the client holds the session's address before the server has done anything and nothing needs to be communicated back. The directory is left behind when the session ends, since readings taken later may follow source paths into it, and removing it is the client's job.

held is a descriptor whose release ends the session. The session watches this fd and serves as long as somebody could still hold it open. That somebody is plural: file descriptors are inherited, so a subshell or child of the client keeps the session alive for as long as it lives, and the session cannot end while a process that might still speak exists. When the last holder closes it, deliberately or by dying, the watch fires and the session closes. A shell that talks after that writes into a fifo whose reader is gone and takes SIGPIPE.

The workspace shows whether the session is up. Its join fifo exists exactly while a session serves, kept truthful by the lock and the sweep (rigs.md), so the client gates on the same directory it named. The boundary case is a server killed with SIGKILL, which removes nothing; its stale fifo stands until the directory is next opened and swept, or removed.

A Failure while serving still sees the session out — every shell released or finished, the fifos gone — before it is returned in Served::failed.

The coprocess convention

Bash's coproc starts a process and hands the script both ends, the process's stdin as a write end and its stdout as a read end. The convention here is that the client keeps the server's stdin as the handle and reads nothing, and serve_coprocess is the server half, taking its own stdin as held.

The client's half is four moves of plain bash: start, probe, load and initiate, let go. The whole script, which also lives in bashprof/__fixtures/book/ where the tool's cli suite runs it as printed:

#!/usr/bin/env bash
# Owns the session: names the workspace, starts the server, probes, loads,
# initiates — and leaves by closing the handle coproc left it.
set -euo pipefail

declare -- workspace="$PWD/prof.d"   # an address is absolute — initiation refuses else
mkdir -p "$workspace"

coproc SERVER { bashprof serve --at "$workspace" --into build.times; }
until [[ -p "$workspace/join" ]]; do sleep 0.01; done   # up exactly while serving

source "$workspace/prelude.bash"    # the protocol's words
source "$workspace/rig.bash"        # the rig's words
BASHPROF_INIT "$workspace"

build() { sleep 0.1; }
BASHPROF_TIMETHIS build build

declare -- handle="${SERVER[1]}"
exec {handle}>&-    # let go: what was held is the server's standard input
wait "$SERVER_PID"  # it sees the session out; this script exits with its status

Three details in that script deserve explanation.

The until gate is there because coproc returns before the server has parsed its arguments, and sourcing the laid files before they exist would fail. The gate polls the one truthful signal. A client joining much later, with the session serving all along, needs no gate.

The server's descriptors are SERVER[0] and SERVER[1], because coproc NAME { … } takes a literal name; that also means one server per shell under this convention. The copy into handle before the close is because exec {name}>&- closes the descriptor a variable names.

The wait returns the server's own exit status, so a client under set -e stops when its server failed. By the time it returns, whatever the server writes is on disk, because the server writes after seeing the session out.

Every other way a shell can join a served session, by hand from the pieces or published to children, is a whole script in joining.md. bashprof's __fixtures/joined/build.bash is a working client of this shape, exercised by its cli suite.

Joining

Joining has two halves, kept apart.

Loading brings the definitions into a shell: source <dir>/prelude.bash for the protocol's words, then source <dir>/rig.bash for the words the rig adds. Both are inert. Initiation opens the channel: one line of client code, BC_JOIN <label> <dir> [word…], or the rig's init function wrapping it. A shell that loaded and never initiated has the words and is not part of the run; a shell that initiates without loading fails at an unknown command.

Both laid files define aliases as well as functions, so the source has to be a command of its own. Anything parsed in the same unit as it — a { …; } group holding both — will not see the words yet (scoping.md).

The exception is stated rather than implied. A run may provision <dir>/bash_env.bash, and whoever provisions it states whether that file initiates, with Provision::Joining putting the rig's joining line at the end, or only defines, with Provision::Definitions. BASH_ENV names that file, so it reaches every non-interactive bash in the subject's tree as it starts. This is how a subject that has never heard of the session comes to join, and it is the only place where initiation happens automatically.

Every way in reaches the same end state, the words defined and the channel open, from a different starting situation. Each is one whole script below, and since the body of work is the same in each, the prologues carry the difference.

bashprof stands in for any program built on the core. Its --reach bash-env|by-hand flags are that tool's spelling of the two provisioning choices, and each tool prints this same list in its own words under run --help and serve --help. The three bashprof-driven scripts also live in bashprof/__fixtures/book/, where its cli suite runs them as printed, and the two tool-free ones are the shapes tests/proofs/serving.rs proves.

Driven, provisioned to join

The subject knows nothing about the session.

#!/usr/bin/env bash
# Started as:  bashprof run --into build.times -- bash provisioned.bash
#
# The provisioned bash_env.bash defined the words and said the join in
# every shell of this tree as it started. Nothing of the protocol appears
# here — this is the way in for programs that never heard of the session.
set -euo pipefail

build() { sleep 0.1; }
BASHPROF_TIMETHIS build build

Driven, definitions only

Under a joining provision every shell of the tree joins, helpers included, so a dependency fetch or a ./configure fills the reading with shells nobody asked about. Here the tool provisions a Definitions file instead. Every shell still gets the words at startup, since BASH_ENV reaches them all, and no shell is joined until its own code says so. The script below joins itself and leaves its helper out.

#!/usr/bin/env bash
# Started as:  bashprof run --reach by-hand --into build.times -- bash by-hand.bash
set -euo pipefail
declare -- workspace="${BASHPROF_SESSION:?the workspace, from the tool}"

# fetch-deps.bash is an ordinary helper of this build — not part of the
# protocol. Like every shell in the tree it wakes up with the words
# defined; nobody initiates in it, so it stays outside the session: it
# runs exactly as it would unwrapped, and nothing it does is heard.
bash "${BASH_SOURCE[0]%/*}/fetch-deps.bash"

# From here on, THIS shell is part of the run.
BASHPROF_INIT "$workspace"

build() { sleep 0.1; }
BASHPROF_TIMETHIS build build

Staying outside the session holds for a shell that does not call the tool's words. If fetch-deps.bash said BASHPROF_TIMETHIS itself, that word would refuse with label BASHPROF is not joined, status 125, at its own call site and before running the wrapped command, so under set -e the helper would stop there. A call site asked for a measurement, and measuring into nowhere would be the worse answer. A helper that shares the tool's words joins too, or is left as it is.

A coprocess client

This script owns the session. It starts the server itself, on a workspace it names and makes, and holds the session open for as long as it runs. Everything here is bash's own — coproc is a keyword and the probe is one file test — and the only files sourced are the two the session laid.

#!/usr/bin/env bash
# Owns the session: names the workspace, starts the server, probes, loads,
# initiates — and leaves by closing the handle coproc left it.
set -euo pipefail

declare -- workspace="$PWD/prof.d"   # an address is absolute — initiation refuses else
mkdir -p "$workspace"

coproc SERVER { bashprof serve --at "$workspace" --into build.times; }
until [[ -p "$workspace/join" ]]; do sleep 0.01; done   # up exactly while serving

source "$workspace/prelude.bash"    # the protocol's words
source "$workspace/rig.bash"        # the rig's words
BASHPROF_INIT "$workspace"

build() { sleep 0.1; }
BASHPROF_TIMETHIS build build

declare -- handle="${SERVER[1]}"
exec {handle}>&-    # let go: what was held is the server's standard input
wait "$SERVER_PID"  # it sees the session out; this script exits with its status

The handle is the write end of the server's standard input, which coproc left in ${SERVER[1]}. Serving::serve_coprocess watches that descriptor, and the session lasts as long as somebody holds it, a subshell that inherited it included. Closing it is the act of leaving, and wait then collects a server that has seen the session out. The convention's fine print is in serving.md.

From the pieces

The workspace arrives as an argument.

#!/usr/bin/env bash
# Started as:  bash join-and-speak.bash <workspace>
#
# No environment and no words of our own: the two laid files are
# everything, and the coordinate arrives as argv. The same load as above, without a
# server to start — and the rig's init function is a raw BC_JOIN here.
set -euo pipefail
declare -- workspace="${1:?the session workspace}"

source "$workspace/prelude.bash"
source "$workspace/rig.bash"
BC_JOIN TELL "$workspace"

declare -- BC_SAY__ARG_LABEL=TELL
BC_SAY STEP joined-from-the-pieces

Publishing to child processes

The client authors its own startup file.

#!/usr/bin/env bash
# Already joined (any way above); wants the processes it starts joined
# too. No laid file initiates, so it writes its own startup file —
# %q is bash's own quoting — and points BASH_ENV at it: bash sources
# that file in every non-interactive child as it starts.
set -euo pipefail
declare -- workspace="${1:?the session workspace}"

declare -- own="${BASH_SOURCE[0]%/*}/own.bash"
printf 'source %q\nsource %q\nBC_JOIN TELL %q\n' \
    "$workspace/prelude.bash" "$workspace/rig.bash" "$workspace" > "$own"
export BASH_ENV="$own"

bash child.bash            # a fresh bash: sources $BASH_ENV, joins, speaks

Which shells the session reaches is always a decision with an author: the run, in its environment closure; the provisioning caller, in its stated Provision; the script, at its own init line. The core runs no initiation.

The proofs behind each way are tests/proofs/starting.rs for provisioned, both ways, and by-hand, and tests/proofs/serving.rs for the coprocess, from the pieces, the client-authored startup file, and an interactive shell typing the same.

The wire

The protocol everything above stands on: what crosses between a shell and the session, byte for byte. Nothing here is API. The chapter quotes the shipped bash itself, in hand copies of src/rig/wire/prelude.bash and its neighbours.

Where things live:

src/rig/wire/
       mod.rs        lay(), mkfifo
       control.rs    `Control` — the join fifo: frames in, `Announced { token, account }` out
       lines.rs      `Lines` — a fifo read end, cut at newlines; `Raw` bytes out
       pipe.rs       `Pipe` — one shell's up + rep: next, drain, answer, close
       message.rs    `Message`, `Verb`, `Stamp`, `Micros`, `Pid`, `Answer`, `Account`, `Line`
       prelude.bash  the client half, shipped verbatim into every workspace

The client surface

A script that takes part uses three words and nothing else from the protocol:

BC_JOIN LABEL DIR word…       # once: bind the label, announce, attach

declare -- BC_SAY__ARG_LABEL=LABEL
BC_SAY a b c                  # ship the arglist and return

declare -- BC_ASK__ARG_LABEL=LABEL
declare -a BC_ASK__ARGS=(a b c)
BC_ASK                        # ship it, block, and run the answer here

BC_SAY and BC_ASK are aliases. That is what puts the answer in the frame that asked, and it is why the two are parametrised by variables rather than by arguments: an alias's trailing words attach to the last command of its expansion, and for BC_ASK that command is the answer itself. BC_SAY has no such tail, so its words ride on the right where a caller expects them.

The label is a lookup key in bash, with __BC__DIR, __BC__FD, __BC__REP and __BC__OWNER as associative arrays over it, which lets one process hold several sessions at once. Rust is never told the label and sees only pipes.

BC_JOIN binds the label to a workspace and refuses the malformed cases: a relative dir, a label that could not name a file, a label already joined in this shell. The words after the dir belong to the caller, and are kept per label, @Q-quoted, announced with every attach, and landed verbatim on Shell::brought. The protocol reserves no word in them and never self-locates.

BC_JOIN() {
    __BC__at="${BASH_SOURCE[1]:-?}:${BASH_LINENO[0]:-?}"
    __BC__word=${FUNCNAME[0]}

    [[ -n ${1-} && $1 != */* && $1 != *[[:space:]]* ]] \
        || { __bc_complain "label ${1-} will not name a file"; return "$__BC__FAILED"; }
    [[ ${2-} == /* ]] \
        || { __bc_complain "workspace ${2-} is not an absolute path"; return "$__BC__FAILED"; }
    [[ -z ${__BC__DIR[$1]-} ]] \
        || { __bc_complain "label $1 is already joined from ${__BC__DIR[$1]}"; return "$__BC__FAILED"; }

    __BC__DIR[$1]=$2
    declare __bc_label=$1 IFS=' '
    shift 2
    __BC__META[$__bc_label]="${*@Q}"
    __bc_attach "$__bc_label"
}

Two aliases carry what the speaking words share. __BC_REACH checks that the label in __bc_l names a session this process holds open, and is where a fork — which inherited the arrays but not a pipe of its own — takes its own. __BC_WRITE is the one shape a message has on the wire. Both are aliases so they run in the frame that already holds the words, which costs no call and leaves one source for each.

alias __BC_REACH='
    [[ -n ${__BC__DIR[$__bc_l]-} ]] \
        || { __bc_complain "label $__bc_l is not joined"; return "$__BC__FAILED"; }
    [[ $BASHPID == "${__BC__OWNER[$__bc_l]}" ]] || __bc_reattach "$__bc_l" || __BC_BAIL'

alias __BC_WRITE='printf "(%s)\n" "${*@Q}" >&"${__BC__FD[$__bc_l]}" || __BC_THROW'

__bc_say is what BC_SAY expands to. Its first line records the subject's own call site, which is what error messages name.

__bc_say() {
    __BC__at="${BASH_SOURCE[1]:-?}:${BASH_LINENO[0]:-?}"
    __BC__word=BC_SAY

    declare __bc_l=${BC_SAY__ARG_LABEL:?BC_SAY__ARG_LABEL}
    __BC_REACH

    declare IFS=' '
    set -- SAY "at=$EPOCHREALTIME" "$@"
    __BC_WRITE
}
alias BC_SAY='__bc_say'

A silent fork never attaches and holds its parent's pipe open for as long as it lives, which is correct, because it could still write on it.

The files

The session lays two definition files and takes a lock. One more file exists only when a run provisions it.

<dir>/prelude.bash    generic, shipped verbatim: the words above, the internals below
<dir>/rig.bash        Rig::bash — the rig's words; definitions only, inert to source
<dir>/lock            flock()ed for the session's life
<dir>/bash_env.bash   only when provisioned: the two sources, then the stated joining, or not

Neither laid file initiates, and the ownership story behind the lock — refusal of occupied workspaces, the sweep of a killed predecessor's fifos — is told once in rigs.md and holds here unchanged.

Two wire-level facts belong to this chapter. Layout::new validates the directory as one line of UTF-8 text, because it crosses into bash and onto the announce line. And re-sourcing a joining bash_env.bash in a child re-runs the join, which is how BASH_ENV reaches a whole tree, while re-running it in a shell already joined is refused by BC_JOIN with already joined and status 125.

The fifos

<dir>/join           the control fifo — many writers, one announcement per shell
<dir>/up.<token>     one shell's pipe — one writer, one line per message
<dir>/rep.<token>    one shell's answers — one line each
made bywritersthe run holdsthe shell holds
jointhe run, at openevery shell, onceO_RDWR: never end of inputopened, written, closed per attach
up.<token>the shell, before it announcesexactly one processO_RDONLY|O_NONBLOCK, async receiverexec {fd}> for its life
rep.<token>the run, on the announcementthe runopen_sender per answerexec {fd}<> for its life

Only one of the three has a framing scheme, because a fifo write is atomic only up to PIPE_BUF, 4096 bytes on Linux. On a shell's own pipe that never matters: one writer means nothing can interleave, so a message wider than PIPE_BUF is still one printf whose pieces arrive in order, and the reader cuts at newlines.

The control fifo is different. Every shell writes its announcement there, the announcement carries the whole account, which is unbounded because it includes $BASH_EXECUTION_STRING, and two shells' bytes may interleave at any PIPE_BUF boundary. Announcements therefore travel in frames.

Frames on the control fifo

Each frame fits in one atomic write and says whether more follow:

<token> + <bytes>\n      a frame with more to come
<token> . <bytes>\n      the last frame

The sender is ten lines of bash. declare LC_ALL=C makes ${#2} and ${2:a:b} count bytes, so a frame is at most 4096 bytes whatever the text holds, and the subject's locale is back on return. A frame may therefore end inside a multibyte character, which reassembly in bytes handles.

__bc_announce() {
    declare LC_ALL=C
    declare __bc_room=$(( 4096 - ${#1} - 4 )) __bc_from=0
    while (( ${#2} - __bc_from > __bc_room )); do
        printf '%s + %s\n' "$1" "${2:__bc_from:__bc_room}" || __BC_THROW
        __bc_from=$(( __bc_from + __bc_room ))
    done
    printf '%s . %s\n' "$1" "${2:__bc_from}" || __BC_THROW
}

On the Rust side Control keeps the unfinished announcements' bytes per token, appends each frame, and on the . frame decodes the whole as UTF-8 and reads it as the Account. Its surface, abridged:

#![allow(unused)]
fn main() {
pub(crate) struct Announced { pub token: String, pub account: Account }

impl Control {
    pub(crate) async fn next(&mut self) -> Result<Announced, Failure>;   // cancellation-safe
    pub(crate) fn close(self) -> Result<(), Failure>;
}
}

A line that is not a frame — no token that could name a file, no + or . after it — ends the run naming the line. close releases every shell announced whole and not yet opened, drops an announcement left in the middle, and unlinks join last.

Attaching

The blocking open is the rendezvous. The shell's side:

__bc_attach() {
    declare __bc_dir=${__BC__DIR[$1]}
    declare __bc_tok="$1::$BASHPID.${EPOCHREALTIME#*[.,]}.${SRANDOM:-$RANDOM$RANDOM}"
    declare __bc_fd __bc_rep __bc_acct

    [[ -p "$__bc_dir/join" ]] || { __bc_complain "no session at $__bc_dir"; return "$__BC__FAILED"; }
    __bc_account __bc_acct "$1"
    mkfifo "$__bc_dir/up.$__bc_tok"                                 || __BC_THROW
    __bc_announce "$__bc_tok" "$__bc_acct" >"$__bc_dir/join"        || __BC_BAIL
    exec {__bc_fd}>"$__bc_dir/up.$__bc_tok"                         || __BC_THROW
    exec {__bc_rep}<>"$__bc_dir/rep.$__bc_tok"                      || __BC_THROW

    __BC__FD[$1]=$__bc_fd
    __BC__REP[$1]=$__bc_rep
    __BC__OWNER[$1]=$BASHPID
}

The shell takes its account, makes its own pipe, announces token and account together on the control fifo, and then blocks opening its pipe's write end. That open completes when the run opens the read end, and the run does that only after reading the whole announcement and making the reply fifo. The ordering holds in both directions: the run cannot open a fifo that does not exist yet, the shell cannot write a message before the run is listening, and by the time the shell is released the run knows everything about it. A shell that says one thing and exits within microseconds cannot get ahead of its own admission.

The [[ -p ]] check before writing matters because > on a missing path would create a regular file where a fifo should be. A session that closed unlinked join, so the check is also how a late shell learns there is nothing to join.

The token, <label>::<pid>.<µs>.<random>, names the two fifos and appears in nothing else. A pid at a microsecond is already unique and the random tail is defence in depth. A collision fails at mkfifo, in the shell that chose the token, and Rust keys nothing on it.

What a line is

Every line on every fifo is a bash array literal with the protocol's words in front, and the shapes never share a channel:

('at=1786786563.138850' 'pid' '4711' … 'command' '')      the account: no verb, clock first —
                                                          once per shell, framed on the control
                                                          fifo, at the join
('SAY'  'at=1786786563.138912' 'REC' 'compiled' 'x.rs')   a message — the shell's own pipe
('ASK'  'at=…' 'which' 'target')                          the other verb; there is no third

Session setup and conversation cannot mix, and each reader enforces its side. Account::read refuses a line with a verb where the clock goes, and a pipe line whose first word is not SAY or ASK is refused as not a verb. Once a shell is admitted its pipe speaks only the two verbs, and each has a word of its own.

Bash's own quoted forms are the codec: ${*@Q} on the way out, declare -a x="$line" or bash-strings' parse_array on the way in. Word boundaries, newlines, tabs and bytes bash cannot display survive with no escape scheme of ours. The Rust value types mirror the wire, abridged:

#![allow(unused)]
fn main() {
pub struct Message { pub verb: Verb, pub stamp: Stamp, pub words: Vec<String> }
pub struct Stamp   { pub sent_at: Micros, pub heard_at: Micros }
}

Stamp holds the two clocks, the sending shell's $EPOCHREALTIME and the run's clock at the read that completed the line. That is why nothing is timed in bash, and why a whole profiling tool is the interval between two stamps.

The shell's pid, $SHLVL and $BASH_SUBSHELL are absent from a message. They cannot change while a shell lives, so they travelled once in the account and are reached through the Shell your reaction was handed.

Two reading conventions are distinct. Message::behind(lead) claims a family of messages by first word, giving a decoder None when another tool wrote it. field(words, key) reads an optional key value payload convention, unrelated to the key=value headers the protocol writes up front.

Asking, and running the answer

An ask is a write, a blocking read, and then the reply is run — but not here. __bc_ask only leaves it in __BC__ANSWER; the alias runs it one frame out, where the call was written.

__bc_ask() {
    __BC__at="${BASH_SOURCE[1]:-?}:${BASH_LINENO[0]:-?}"
    __BC__word=BC_ASK
    __BC__ANSWER=(__bc_no_answer)

    declare __bc_l=${BC_ASK__ARG_LABEL:?BC_ASK__ARG_LABEL}
    __BC_REACH

    declare IFS=' '
    set -- ASK "at=$EPOCHREALTIME" "${BC_ASK__ARGS[@]}"
    __BC_WRITE

    declare __bc_line
    IFS= read -r __bc_line <&"${__BC__REP[$__bc_l]}" || __BC_THROW

    declare -ga __BC__ANSWER="$__bc_line"
}
alias BC_ASK='__bc_ask; "${__BC__ANSWER[@]}"'

The reply pipe was opened <> at attach, so the read waits for an answer instead of hitting end of input. declare -ga …="$line" is bash parsing the reply as an array literal, using the syntax it prints itself. ${*@Q} joins on the first character of IFS, hence the scoped IFS; the full scoping story is scoping.md.

The two statements are sequenced with ; rather than joined with &&. Under errexit a failing operand of && that is not the last is exempt, so a wire fault there would be stepped over silently. Sequenced, a fault stops the shell; and where errexit is off, __BC__ANSWER was reset to __bc_no_answer before anything could fail, so the ask reports 125 rather than running an answer meant for an earlier question.

BC_ASK exits with whatever the answer returned, which is how a reply that says no reaches the subject as an ordinary, testable status.

On the Rust side the answer is a value with five constructors:

#![allow(unused)]
fn main() {
pub struct Answer(Vec<String>);

impl Answer {
    pub fn of(command, args) -> Self;   // any command, any argv
    pub fn status(code: u8) -> Self;    // `__bc_status code`
    pub fn unknown() -> Self;           // 127, bash's own "command not found"
    pub fn ok() -> Self;                // 0
    pub fn returning(code: u8) -> Self; // `return code`, in the frame that asked
}
}

status and returning differ in how far they reach. __bc_status is a prelude function, so return inside it ends that function and leaves the ask with a status the script can test. returning sends bash's own return, which runs in the asking frame and ends the function holding the call site — a capability the alias buys, and one to reach for deliberately.

A word the rig answers with has to be a function. The answer runs as "${__BC__ANSWER[@]}", and that expansion names commands, not aliases, so a saying word meant to be called from a reply is defined as a function even where the same rig gives scripts an alias.

Pipe::answer opens rep.<token> fresh for each answer with open_sender. That open is the liveness mirror of the join's blocking open: it never blocks, and ENXIO means the asker died. The write is awaited, so an answer past the pipe's buffer holds up its own shell alone. An answer carrying more bash than one command's worth writes a file and answers Answer::of("source", [path]), and assignments a sourced step makes are global and reach the client.

When the protocol itself fails

The prelude may not use set -e — the subject decides its own options — so every command in it that can fail is guarded:

shopt -s expand_aliases

alias __BC_BAIL='return $?'
alias __BC_THROW='{ __bc_complain "${FUNCNAME[0]} ($?)"; return "$__BC__FAILED"; }'

These are aliases because return must act in the frame that failed. That is the one shell option the protocol turns on, expand_aliases, and it stays on.

What a subject sees when the instrumentation breaks:

BC_SAY: label NOPE is not joined at build.bash:42
BC_SAY: __bc_attach (1) at build.bash:7

One line per fault, naming the subject's own call site, with status 125 — the code env and timeout use when the wrapper rather than the payload failed. Three outcomes stay distinguishable at every call site: the instrumentation broke at 125, the answer ran and said no with its own status, and the command was fine at 0.

Three spots are unguarded. The array assignment in __bc_ask cannot fail, running the answer produces the result, and a BASH_ENV file's own source has its status discarded by bash.

Lifecycle

End of input on up.<token> is the goodbye. The run alone holds the read end, so when the last write-end holder is gone, whether the shell exited or closed its fd, the task sees end of input, and that moment is Attended::parted. There is no PART verb and nothing to send.

At close the run releases every announced-but-unopened pipe, whose shell takes SIGPIPE at its next write; each task reads what its pipe already holds; a shell's two fifos are unlinked when its task ends; and join is unlinked last. A kept workspace therefore holds fifo names only for shells still alive.

See also

  • rigs.md — the session loop these fifos feed
  • shell.md — every word the account carries
  • measurements.md — the kernel facts (PIPE_BUF, fifo semantics) and what each proof establishes

The shell

src/shell.rs.

#![allow(unused)]
fn main() {
pub struct Shell {
    pub nth: usize,        // the order it joined in, counting from zero
    pub pid: Pid,
    pub shlvl: u32,
    pub subshell: u32,     // $BASH_SUBSHELL
    pub joined: Stamp,     // when it joined, on both clocks
    pub bash: Bash,        // which bash, and how it was invoked
    pub options: Options,  // what it had switched on then, which may change after
    pub brought: Vec<String>, // the words its join carried, verbatim
}

pub struct Bash { pub version: Version, pub binary: PathBuf, pub zero: String, pub invocation: Invocation }
pub struct Invocation { pub command: Option<String>, pub standard_input: bool, pub interactive: bool }
pub struct Options { pub flags: Flags, pub shellopts: Vec<String>, pub bashopts: Vec<String> }
}

A shell opens with an account of itself, stated by the shell rather than inferred from what it went on to write. The account travels with the announcement, on the control fifo, in frames, before the shell's pipe is opened, so the run knows the whole of it before releasing the shell. The account is what makes a shell, and a Message presupposes one, so the account is never a message.

One prelude function builds it, and reads as the checklist of what a shell states about itself. These are the shipped bytes:

__bc_account() {
    declare __bc_out=$1 IFS=' '
    declare -a __bc_meta="(${__BC__META[$2]-})"
    set -- "at=$EPOCHREALTIME" \
        pid       "$BASHPID" \
        shlvl     "$SHLVL" \
        subshell  "$BASH_SUBSHELL" \
        versinfo  "(${BASH_VERSINFO[*]@Q})" \
        bash      "$BASH" \
        zero      "$0" \
        flags     "$-" \
        shellopts "$SHELLOPTS" \
        bashopts  "$BASHOPTS" \
        command   "${BASH_EXECUTION_STRING-}" \
        brought   "(${__bc_meta[*]@Q})"
    printf -v "$__bc_out" '(%s)' "${*@Q}"
}

One array literal, clock first and no verb, written into the caller's local. Every entry is passed as bash reports it, and Shell::of decides what any of it means. Adding a fact is a word here and a field there.

brought is the entry the client writes: the words its join carried, from BC_JOIN LABEL DIR word…, as one nested literal in the shape versinfo takes, landing verbatim on Shell::brought. It is an arglist like a message's. The protocol reserves no word in it, and key value pairs read with field are the client's own convention. A fork's reattach announces its label's words, and a child process re-derives them at its own join.

None of this changes while a shell lives. A subshell has a $BASHPID of its own and joins as a shell of its own, and set refuses -i, -c and -s, so Invocation is settled at startup. Options is a snapshot, since a subject may set -e at any point.

Why a shell states what it is

A walk cannot be read without it. 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: Invocation::from_a_file is command.is_none() && !standard_input.

An interactive shell joins by typing its own way in, loading the pieces and saying the init, because bash reads BASH_ENV for non-interactive shells only. The mechanism is indifferent to this: the same BC_JOIN runs however the shell got there, under either orchestration. See scoping.md and stack.md.

What the account leaves out

Who forked whom. A fork inherits its parent's pipe descriptor and takes its own on its first word; its descent from a particular shell is not reported, because bash does not track it either. What bash does know, $SHLVL and $BASH_SUBSHELL, is reported as bash states it.

See also

  • wire.md — how the account travels
  • rigs.md — where a shell enters a reaction
  • stack.md — the walk that cannot be read without the shell

The call stack

src/stack/stack.bash writes it, the rest of src/stack/ reads it. One instrument and one reader, shared by every tool that reports where a shell is.

What bash keeps

Five parallel arrays, maintained by the shell itself:

FUNCNAME     ('__bc_stack' 'BASHCAP' 'f__C' 'f__B' 'main')
BASH_SOURCE  (…)                                            aligned 1:1
BASH_LINENO  ('4' '8' '9' '10' '0')                         shifted by one
BASH_ARGC    ('2' '0' '0' '0' '1')                          aligned 1:1
BASH_ARGV    ('2' 'walk' 'x')            one flat stack, groups reversed

BASH_ARGC and BASH_ARGV exist only under extdebug; bashcap's book covers how that is turned on. Expanding an unset array is not an error, set -u included, so an instrument writes all five unconditionally.

The instrument

The whole instrument as shipped, from src/stack/stack.bash, whose header comment carries the contract:

__bc_stack() {
    declare -n __bc_stack_out="$1"

    __bc_stack_out+=(
        skip    "$2"
        pwd     "$PWD"
        funcs   "(${FUNCNAME[*]@Q})"
        sources "(${BASH_SOURCE[*]@Q})"
        lines   "(${BASH_LINENO[*]@Q})"
        argc    "(${BASH_ARGC[*]@Q})"
        argv    "(${BASH_ARGV[*]@Q})"
    )
}

Seven expansions, with nothing sliced, summed, reversed or looped over. Everything that decides what a walk means happens on the Rust side, where it can be checked without running a shell.

$PWD is there because a relative BASH_SOURCE is relative to something and nothing else records what. It changes under the subject's feet, so it rides with every walk rather than with what the shell said of itself once.

$1 names the caller's own array, so nesting works and no global is involved; see scoping.md. The nameref is __bc_stack_out, a name no caller would choose, because a nameref pointing at itself warns and discards the write instead of failing.

$2 is how many leading frames belong to the instrument, counting __bc_stack's own. Each caller passes what it is. bashcap's __bc_capture forwards the number the word gave it — 3 under BASHCAP, for the word, the capture and the walk, and 2 under WITH_BASHCAP, whose own frame is the call site. bashprof's __bp_begin passes 3 plus the shift a wrapper declared.

Each section is a bash array literal, read back with parse_array; see bash-strings: values.

The reader

#![allow(unused)]
fn main() {
pub struct Frame {
    pub site: Site,
    pub source: Source,
    pub lineno: u32,
    pub args: Option<Vec<String>>,
}

/// What a frame is. `main` and `source` are bash's own words, not names;
/// `Shell` is a frame bash records no word for at all.
pub enum Site { Function(String), Script, Sourced, Shell }

/// Where its code came from. Only `File` is a path.
pub enum Source { File(PathBuf), Environment, Prompt, Shell }

impl Source {
    pub fn found(&self) -> Option<&Path>;     // a file, and it is there
    pub fn missing(&self) -> Option<&Path>;   // a file, and it is not
}

/// A walk, innermost first. Never empty, and one array in JSON.
pub struct Stack { /* private */ }

impl Stack {
    pub fn of(frames: Vec<Frame>) -> Option<Self>;   // None for no frames
    pub fn top(&self) -> &Frame;                     // where the walk was taken
    pub fn below(&self) -> &[Frame];                 // the frames above it
    pub fn frames(&self) -> impl Iterator<Item = &Frame>;
}

pub struct Args<'a>    { pub argc: &'a str, pub argv: &'a str }
pub struct Columns<'a> { pub skip: usize, pub pwd: &'a str, pub funcs: &'a str,
                         pub sources: &'a str, pub lines: &'a str,
                         pub args: Option<Args<'a>> }

impl<'a> Columns<'a> {
    pub fn of(words: &'a [String]) -> Result<Self, Failure>;

    /// Against the shell the walk was taken in — see `shell.md`.
    pub fn frames(&self, shell: &Bash) -> Result<Stack, Failure>;
}
}

A walk is one value rather than a head and a tail. Which frame is the call site is at(), and a Stack cannot be empty: Stack::of is the one place that can say so, and Columns::frames turns that into a Failure where the message is read, so nothing downstream carries the question.

Three indices are undone on the Rust side, all of them arithmetic. skip drops the instrument's own frames, and is at least 1 and never past the end of the walk. The line shift and the argument stack have sections of their own below.

The line each frame is executing

BASH_LINENO[i] is where frame i was called from, so where frame i is executing is BASH_LINENO[i - 1]. LINENO holds the missing cell at the innermost end, and the two together are the whole vector:

frame:            report  inner  outer  main
executing at:        3      9     12     14      = [LINENO] ++ BASH_LINENO[..n-1]
BASH_LINENO   = (   9  ,  12  ,  14  ,  0  )
LINENO        =     3

LINENO is not shipped, since it would be the emitter's own line, and `skip

= 1` drops that frame by construction.

The last BASH_LINENO cell is left over, and it holds where the walk itself was entered. Bash pushes a frame for the top level of a script file and for nothing else, so that cell tells the two apart. Measured on 5.3.9:

how bash was startedlast cell
a script file0
a script file defining a function called main0
a file sourced from a script file0
bash -c '…'the line the walk was entered from
a shell fed on standard inputthe same
a file sourced from either of thosethe same

Where it is not 0 there is one frame above the outermost that FUNCNAME never names, and the cell is its line. That frame is Site::Shell, built from what bash did report. A make recipe is the everyday form of it, since make runs each one through $(SHELL) -c.

Bash's own words

Measured against 5.3.9. eval, traps, subshells and command substitution add no frame.

in FUNCNAME
mainthe top level of the script bash was given
sourcethe top level of a file the subject sourced
in BASH_SOURCE
environmentthe function came in through the environment (export -f)
mainthe function was defined at an interactive prompt
$0the code came from a -c command line or from standard input

The last is whatever $0 is — bash, or any name a caller passed — so a walk alone cannot tell it from a file of the same name. $0 and how bash was started are in what the shell said when it joined, and Columns::frames is handed that: the word reads as Source::Shell only in a shell bash was given no script file for, and where it was, $0 is that script and reads as the path it is. A script defining a function called main or source is indistinguishable from bash's own use of those words, since bash reports the same string either way.

Where a source path lands

BASH_SOURCE holds the path as it was written, relative or not, and never normalised:

$ cd probe && bash sub/main.bash
BASH_SOURCE=('sub/../lib.bash' 'sub/main.bash')

stack.bash therefore ships $PWD with the walk, and Source::File is that joined with what bash said: absolute, with nothing resolved, no symlink followed and no .. collapsed.

Bash records what a relative path was relative to at the time of the walk, never at the time the file was sourced. A subject that changed directory in between leaves a path that resolves to nothing, which is what missing reports. The path was true when it was written; a reading reports it as it chooses, and a rig whose reading outlives the run keeps its own workspace so the instrument's frames stay readable, as rigs.md covers.

Because skip >= 1, the i - 1 index above is in range for every reported frame, so the off-by-one is unrepresentable rather than guarded.

The argument stack

BASH_ARGV is one flat stack of words, and BASH_ARGC[i] is the width of frame i's group in it. A group's offset is the sum of the widths before it, and its contents are stored reversed. Summing forward and reading each group backward gives the arguments in the order the call was written.

When arguments are absent

BASH_ARGC aligns 1:1 with FUNCNAME only where the shell was recording. Turn extdebug on part-way and it is short, and short means every width belongs to a different frame.

Alignment is the test rather than shopt -q, and an unaligned record is carried as absent. Frame::args is therefore an Option, where None is not recorded and Some([]) is called with none. A tool that never wants arguments omits the two sections entirely and gets the same None.

A record that lines up but claims more arguments than were sent is corrupt, and fails the run.

Columns rather than rows

An instrument could assemble whole frames in bash and ship them as an array of arrays. That costs one more level of @Q, which re-escapes every quote, and a walk over BASH_ARGV written in bash. Measured at depth 8 with three arguments per frame, 4000 iterations, against an empty-loop floor of 2.7 µs:

µs/oppayload bytes
assembling rows, with the argument walk201522
six raw ${arr[*]@Q} expansions21314

The columns are also closer to what bash keeps. BASH_ARGC plus BASH_ARGV is a width-prefixed flat word stream, which is LinkedArr's shape. Shipping them as they are puts the index arithmetic where the compiler can see it, and where it is checked without running bash.

Who uses it

Any word that reports where a shell is. It reaches the walk through stack::with_walk, which puts stack.bash in front of the rig's definitions in Rig::bash, and it passes its own instrument depth: one frame for the word and one for the walk. Each tool's own book lists which words it defines and when they record arguments.

See also

Scoping

Every bash file this crate ships — the prelude, the walk — and every tool's instrument built on it runs inside the subject's frames rather than beside them. Where a name binds therefore decides what a helper writes and what its continuation reads. This chapter is the closed set of scoping facts the shipped bash stands on, each measured against bash 5.3.9.

One stack, resolved by name at run time

Variables live in a stack of scopes: the global scope at the bottom, one frame per live function call above it. A name is resolved by walking from the innermost frame outward to the first frame holding a binding for it, so what a function sees depends on who called it.

local and declare inside a function are the same builtin behaviour. They create a binding in the current frame, shadowing any outer one, and it is released when the function returns. declare -g writes the global scope instead. The shipped bash says declare throughout, because it also works at a script's top level, where local is an error — and the words are written to be called from either.

A bare assignment writes the innermost existing binding, and creates a global when there is none:

where X=(…) inside a callee lands
a caller declared Xthat caller's frame
no frame declared Xthe global scope, outliving every frame

When the walk finds nothing: set -u

Under set -u an expansion whose name binds nowhere is an expansion error rather than a command failure. It happens while the command is being built, so there is no command to fail and no status to test:

echo "$nope" || echo caught        # 'caught' is never printed

The shell exits, whatever frame it was in and whether it was running a script or sourcing one. The || __BC_BAIL and || __BC_THROW discipline sits one layer above this and cannot see it (wire.md). The expansion itself is the only place it is answerable.

A name the instrument did not set carries its default at every expansion of it; a name it set one line earlier carries none. Where the tool set the name, an unbound one is a defect and killing the shell is the right outcome. The first list is short and closed:

unbound means
${1-}, ${__BC__at:-?}, ${BASH_SOURCE[1]:-?}a client called a word wrong

A tool's effect keeps its own short list under the same rule; bashprof's ${__BP_inside-} reads as the outermost call, stated in its own book.

${x-} rather than ${x:-} wherever empty and unset are different facts.

Which forms are safe is not guessable, and these were measured on 5.3.9:

"$@", "$*" with no positional parametersfine — exempt since 4.4
"${arr[@]}", "${arr[*]@Q}" on an unset arrayfine
"${arr[0]}", "${#arr[@]}" on an unset arrayfatal
"$1" with no argumentfatal
${!PREFIX@} with no matches, "${BASH_REMATCH[@]}" unsetfine

declare x leaves x unset; declare x= sets it empty. BASHPROF_TIMETHIS depends on the second, since after an empty hook has run $__BP_id has to be empty rather than unbound.

The two ways in differ in what set -u sees

Under a provisioned run, bash reads BASH_ENV while the shell is still starting, before the subject's own set -u line, so only function bodies later run under it. A client that joins by its own lines has set -u on first, so the top level of everything it sources — the prelude, the rig's definitions — and its own join line run under it too. Every __BC__* name is assigned before anything reads it, which is what makes the second case hold.

IFS comes from the subject, and [*] reads it

"${arr[*]}" joins with the first character of $IFS, and the subject is free to set that to anything. A shipped file that joins an array takes an IFS of its own for that frame:

__bc_account() { declare IFS=' '; … }   # prelude: the version is "(${BASH_VERSINFO[*]@Q})"
__bc_say()     { declare IFS=' '; … }   # prelude: the line is "(${*@Q})"
__bc_capture() { declare IFS=' '; … }   # bashcap's effect does the same

declare IFS=' ' is released on return, including where the subject had IFS unset: the binding is dropped rather than restored to a value, so the subject's own state comes back whichever it was. A subject running under IFS=, is what finds this, since the array arrives comma-joined and reads back as one element.

[@] does not join and needs nothing. Neither does printf -v x '%s ' "${@@Q}", which writes its own separator.

Proved by tests/proofs/transparency.rs::a_clients_own_trap_and_ifs_are_untouched, which sets IFS=, and then reads the version back off the shell.

LC_ALL comes from the subject, and ${#s} reads it

${#s} and ${s:a:b} count in the shell's locale. The one place a shipped file has to count bytes, cutting the account into frames of at most PIPE_BUF, takes LC_ALL=C the same way, for that frame:

__bc_announce() {
    declare LC_ALL=C
    declare __bc_room=$(( 4096 - ${#1} - 4 )) __bc_from=0
    …
}

Two declares rather than one, because the words of a declare are expanded before it runs, so ${#1} in the same statement would still count characters. The same ordering is why a word's parameters cannot be set as a command prefix on the call that reads them — see below. An assignment to LC_ALL takes effect at once, and the return puts the subject's LC_ALL back, unset included (measurements.md).

The slot pattern

A helper that computes a value and then calls a continuation cannot hold that value in its own frame. The continuation runs while the helper is still on the stack, but the value belongs to the span rather than to the helper. The frame that owns the lifetime declares the slot, and the helper writes through to it.

span() {
    declare -a CAPTURED             # the slot; this frame owns its lifetime
    with_capture continuation "$@"
}

with_capture() {
    CAPTURED=(…)                    # resolves to span's binding
    "$@"                            # the continuation reads it from there
}

Three properties follow from the declare, and none hold without it. The write lands in span's frame, so the binding is released when span returns. A nested span declares its own, so an inner one leaves the outer one intact. And nothing reaches the global scope.

The initialiser is not part of the mechanism: declare -a CAPTURED and declare -a CAPTURED=(…) behave identically here, since nothing reads the slot between the declaration and the helper's write.

Nesting three spans deep, reading the slot after the sub-call returns:

with the declaration                  without it
BEGIN A   X='depth-A'                 BEGIN A   X='depth-A'
BEGIN B   X='depth-B'                 BEGIN B   X='depth-B'
BEGIN C   X='depth-C'                 BEGIN C   X='depth-C'
END   C   X='depth-C'                 END   C   X='depth-C'
END   B   X='depth-B'                 END   B   X='depth-C'
END   A   X='depth-A'                 END   A   X='depth-C'

The two ways it inverts

When the helper declares the slot, the binding is in the helper's frame. The continuation still reads the value, because it runs below the helper, and the span reads whatever it declared, because the helper's binding was released before control returned:

span() { declare -a X=(marker); helper cont; }   # span sees 'marker'
helper() { declare -a X=(computed); "$@"; }      # cont sees 'computed'

When nobody declares the slot it lands in the global scope. It survives the span, and a nested span overwrites the enclosing one's, as the right-hand column above shows.

Namerefs

declare -n out="$1" binds out to whatever name $1 holds, resolved by the same outward walk at each use. It carries the target's name explicitly rather than relying on both sides agreeing on one, and it nests: each caller passes its own slot's name.

A nameref whose own name equals its target warns and discards the write:

bash: local: warning: X: circular name reference

Execution continues and the assignment is lost, so a nameref parameter needs a name no caller would choose. The __bc_ prefix keeps this unreachable.

unset -n releases the binding without touching the target.

Aliases, and what they can carry

BC_SAY and BC_ASK are aliases, because an alias expands textually at the call site: what it expands to runs in the caller's frame, which is what lets an answer declare there. Everything below follows from that, and each was measured on 5.3.9.

An alias is expanded when the command using it is parsed, not when it runs. The prelude is sourced as its own command, so anything parsed afterwards sees the words — including a function defined later in the same file. What does not work is using one in the same parse unit that defines it:

{ source "$dir/prelude.bash"; BC_SAY hello; }   # the whole group is parsed first

An alias's trailing words attach to the last command of its expansion. BC_SAY expands to one command, so words written after it are the message. BC_ASK expands to two — the ask, then the answer — so words after it would become the answer's arguments; its payload goes in BC_ASK__ARGS instead.

A word built over the core is one command for the same reason. Written as a prefix assignment plus a call it composes wherever a command does:

alias STAGE='BC_SAY__ARG_LABEL=DEPLOY BC_SAY STAGE'

Written as several statements it does not. Only the first would be guarded by a ||, and the rest would run unconditionally:

alias STAGE='declare -- BC_SAY__ARG_LABEL=DEPLOY; BC_SAY STAGE'   # not this
false || STAGE compile        # the declare is skipped; the say happens anyway

A command prefix reaches the callee at run time and is released after it, which is what makes the one-command form work. It cannot be used for a value the same command expands, because a simple command expands its words before performing its assignments.

$? does not survive a word. The commands an alias runs before the payload set it, so a status is captured on its own line first:

some_command
declare -i rc=$?
STAGE "finished $rc"

An answer cannot name an alias. It runs as "${__BC__ANSWER[@]}", and that expansion names commands, so a rig gives scripts an alias and gives its own answers a function — eval is the exception, since it re-parses and expands aliases again.

The parameters are ordinary variables, so a frame that sets one is visible to anything it calls. Declaring them keeps that to the frame that meant it, and the <WORD>__ARG_ prefix keeps them out of a subject's way; a callee that reads one without setting it will see the value an enclosing frame set.

Subshells

A subshell receives a copy of the whole scope stack. Writes inside it resolve against that copy and do not return:

span() { declare -a X=(before); ( X=(inside) ); }   # X is still (before)

The slot pattern works unchanged inside a subshell and carries nothing out of one. This is the same boundary that makes a buffered record flushed from a subshell's EXIT unrecoverable (measurements.md).

$? and the frame

$? survives only as the first command of a block, and a right-hand side is expanded before any name in the same statement is assigned. A status is therefore captured on its own line, before any other command in the frame runs:

"$@"
declare __rc=$?

See also

  • measurements.md — traps, extdebug, mkfifo, and the rest of what bounds the bash design
  • wire.md — the guards, which are aliases so that return acts in the frame that failed

Measurements and limits

Numbers measured on this machine (Linux 6.x, bash 5.3.9), the bash and kernel constraints that bound the design, and what each proof establishes.

The kernel, on fifos

a reader opens O_RDONLY|O_NONBLOCK, no writer has ever attachedquiet — not POLLHUP, not for 300 ms
a writer attaches, no datastill quiet
a writer writesPOLLIN
a writer writes and exitsPOLLIN|POLLHUP, data intact
all writers gone, having attachedPOLLHUP
a non-blocking reader openunblocks a blocking writer open
parent exits, a subshell still holds the inherited fdPOLLIN only — POLLHUP waits for the subshell
a reader opens then closesthe blocked writer unblocks, and its next write takes SIGPIPE

POLLHUP means that a writer attached and all writers are now gone. Nothing in that is ambiguous between not yet and no longer, and no state has to be kept beside the pipe, which is what makes end of input on a shell's pipe its goodbye and the blocking exec {fd}>up.$tok the rendezvous.

many writers on one fifo
each writes 4096 bytes per writeevery line arrives whole
each writes 4097lines interleave

PIPE_BUF is 4096 on Linux, and it bounds a write rather than a line. The control fifo therefore carries frames of at most 4096 bytes, and a shell's pipe, having one writer, carries lines of any length.

tokio, on the same

Verified with a scratch crate on tokio 1.53, current-thread runtime:

pipe::OpenOptions::new().read_write(true).open_receiver(join)quiet with no writer; a writer that wrote and left leaves it open — no end of input
pipe::OpenOptions::new().open_receiver(up)O_RDONLY|O_NONBLOCKquiet with no writer ever; a bash that attached, wrote three lines and exited yields the three lines then end of input
a bash blocked in exec 9>upreleased by open_receiver, exactly when it was opened
pipe::OpenOptions::new().open_sender(rep) with no readerENXIO immediately
Sender::write_all of 100 KB to a bash readcompletes; bash reads 100 000 bytes
AsyncFd<pidfd>::readable()wakes when the process exits
AsyncFd<read end>::readable() when the writer closeswakes, is_read_closed

The whole descriptor layer is stock tokio and nothing is hand-rolled.

What things cost from bash

µs
( : ) — a subshell341
bash -c ':'1471
bash -c ':' with a 200-line BASH_ENV1884
exec {fd}>fifo + close, a reader present8
printf one message to a fifo12
mkfifo on this box, which is uutils in Rust2088
mkfifo from GNU coreutils (/bin/true measured 680) or busybox~600
a static 800 KB mkfifo — the floor: fork plus a bare exec514

Bash has no builtin that makes a fifo. mkfifo, mknod, mkdir and ln are all external commands, the loadable mkfifo builtin is not shipped by default anywhere, and every fork-free way to wait for a fifo the run would make instead runs into the same wall: a fifo gives one process a non-consuming wait only through open, and a shared open cannot say which shell it releases.

A shell that attaches therefore forks once, and that is the cost of a pipe per shell. It is paid at source by every bash process under BASH_ENV, and by every fork that speaks. An ask forks nothing.

The token

unique
$BASHPID.${EPOCHREALTIME#*[.,]}2000 / 2000
the same plus ${SRANDOM:-$RANDOM$RANDOM}2000 / 2000

over 2000 tokens from nested subshells, background forks and child processes. One process's clock advances between two reads (measured 4 µs apart). SRANDOM is 5.1+ and fresh per subshell; RANDOM is reseeded per subshell in 5.x and inherited before 5.0. A duplicate token fails at mkfifo in the shell that chose it, and Rust keys nothing on it.

Loopback TCP, measured and rejected

/dev/tcp/127.0.0.1/<port> would remove every fifo, the fork and the rendezvous: printf to it costs 13 µs against a fifo's 12, and a connect 46 µs. But bash cannot set TCP_NODELAY, and a shell that writes twice and then asks hits Nagle against the receiver's delayed ACK:

write, write, ask, readµs per round
over loopback TCP41 015
over loopback TCP with the receiver re-arming TCP_QUICKACK on every read63
over two fifos33

Cost in bash, per message

Minimum of seven runs of 4000:

µs
build the message, no I/O13.8
write it to the pipe~15.5
sending, inlined at every call site21
sending through one bash function28

What the word costs around that write, measured on a 26-word message with the same guards each way, minimum of seven runs of 3000:

µs
a dispatching function calling a sender — two frames, words copied twice69.1
BC_SAY: one frame, the write shared as an alias59.5
a rig's word as a one-command alias over it55.6
a rig's word as a function taking "$@"87.2

One frame instead of two, and the words expanded once instead of twice, is where the difference sits. A rig's word costs nothing extra while it is an alias, since an alias is text at the call site; written as a function it pays a frame and a copy of "$@", which is the price of being callable from an answer.

Message assembly dominates either way, and a tool reading real state costs far more: a full bashcap snapshot is ~480 µs. Nothing about the shell rides on a message — its pid, $SHLVL, $BASH_SUBSHELL and version are in the account, said once — and what is left in front of a client's arglist is the verb and one at= clock.

The frame walk

Assembling whole frames in bash, against shipping bash's five stack arrays as they are. Depth 8, three arguments per frame, 4000 iterations, empty-loop floor 2.7 µs:

µs/oppayload bytes
rows, with the argument walk in bash201522
six raw ${arr[*]@Q} expansions21314

See stack.md.

What a function layer costs an instrument

An instrument that separates its layers into functions puts every layer's frame on the stack of everything measured below it, and every walk carries them. BASHPROF_TIMETHIS as one function against the same word as a CPS spine of three, BEGIN payload in bytes by how many measured calls enclose it:

enclosing measurementsone functionspine of three
0349537
14711112
25841678
36972244
per level~113~566

What costs this is a layer still on the stack while the measured call runs. __bp_begin sends the BEGIN and returns before "$@", so it stands in its own walk and in nobody else's, at about 77 bytes and one frame per level. The one extra call per measurement, the END being inline in the word, costs about 1.0 µs.

What a callee's frame gives back

declare restores what was there, unset included. A callee taking declare IFS=' ' leaves an unset IFS unset and an empty one empty, so the distinction a manual restore has to make by hand, bash makes itself.

A command-prefix assignment scopes to the call, restores the previous state, unset included, and reaches expansions inside it, including through a local -n nameref.

Cost of a snapshot

bashcap run over 2000 BASHCAP calls at a six-deep stack, wall clock per snapshot — the whole path, bash through the wire to the decoded JSON:

untraced--trace-calls
the walk assembled in bash572 µs737 µs
the walk shipped as columns482 µs527 µs

Memory

BashCap decodes and writes in hear, so a snapshot reaches the file as it arrives. Resident memory does not track the run:

snapshotspeak RSSoutput
2007.7 MB0.19 MB
2 0007.8 MB1.9 MB
20 0007.5 MB18.9 MB

What the proofs establish

tests/proofs/, over the public API only. Each spawns real bash to cover one mechanism that cannot be checked by reading the source. One file per subject.

attaching.rsestablishes
a_shell_that_speaks_once_and_leaves_loses_nothinga bash -c that joins, says one thing and exits within microseconds loses nothing: the blocking open is the rendezvous
a_fork_that_speaks_is_a_shell_of_its_own_and_parts_on_its_owna fork takes a pipe of its own, its parted precedes the parent's, and the parent keeps its own words
two_labels_in_one_process_are_two_shellstwo BC_JOINs in one rig's bash are two pipes and two shells with one pid
a_label_nobody_joined_is_an_error_by_absencea word on an unjoined label names it and the call site, returns 125, and the run knows nothing
an_account_of_any_size_arrives_wholea bash -c with a 21 KB command of — six frames, cut inside characters — reads back byte for byte as Invocation::command
many_shells_announce_at_once16 shells with 6 KB commands announce together; every account whole and its own
the_words_a_join_brings_are_on_the_shellBC_JOIN KEEP <dir> role worker … lands verbatim on Shell::brought, in the fork too; field reads the pairs
transport.rsestablishes
every_descendant_shell_reaches_the_runsubshells, command substitutions and child processes are all shells; five of them
many_shells_at_once_arrive_whole_and_apart8 shells × 80 messages, half 9000 bytes, each pipe carries one shell's words
a_message_of_wide_characters_arrives_whole6000 per message, longer than a pipe's atomic write, character for character
nothing_is_lost_at_the_end200 messages written immediately before exit are read after the subject is gone
a_newline_inside_a_value_is_escaped_not_a_linea value containing \n arrives as one word
transparency.rsestablishes
a_signalled_subject_is_reported_and_loses_nothingSignal(15), .shell_code() == 143, and what was said before the signal survives
a_clients_own_trap_and_ifs_are_untoucheda client's own EXIT trap and IFS survive a message going out; the version read back under IFS=,
a_clients_own_locale_is_untouched_by_a_wide_messageLC_ALL before and after a 9000-byte message
answering.rsestablishes
a_session_survives_every_way_of_answering57 asks across ten shells, every answer form, one deliberately slow, one 100 KB, mixed with a message too wide for one write
an_answer_may_wait_on_another_shells_wordan answer awaiting a Notify that another shell's hear triggers completes — serving is concurrent
starting.rsestablishes
the_closures_return_is_the_subjects_whole_environmentRig::bash puts the rig's word in the subject and a child it starts; so does a variable from the run's closure, and one set with env on the command line; DEPLOY_SESSION, which the closure did not return, is absent in both — the core adds nothing
the_command_line_is_run_as_askedthe run starts the program the argv names, with nothing appended
a_subject_may_join_by_hand_where_it_choosesa rig whose environment is only the client's own DEPLOY_SESSION pair: the script loads the pieces and says BC_JOIN itself; children that did nothing are not shells
a_definitions_file_leaves_initiation_to_the_scriptProvision::Definitions: the words in every shell, the channel in none, until the script's own join; the word before it went nowhere
serving.rsestablishes
a_shell_that_joined_is_heard_until_it_lets_goa client's words and its subshell's arrive; the session ends with the handle; the client's status is its own. Every serving proof gates on the join fifo and joins by the directory it named; the fifo brackets the session
a_shell_the_session_outlived_is_left_to_its_own_devicesa client that released the handle while running has parted: None, and its next word takes SIGPIPE
a_joined_shell_may_publish_to_its_childrenthe client authors its own startup file (%q-spelled) and exports BASH_ENV to it; the child joins at startup
a_child_may_be_told_the_workspace_as_an_argumentthe coordinate travels as argv alone; the child loads the pieces and joins itself, naming the BASH_ENV it does not have
a_shell_says_what_it_is_rather_than_being_guessed_atan interactive shell joins by sourcing, and says -i, -s, no command line
an_occupied_workspace_is_refusedthe lock is taken before anything is touched: a second server on the same directory is refused whole while the first serves on
a_killed_predecessors_leavings_are_sweptstale fifos in a prescribed workspace are removed under the lock at open; the session serves and closes clean
a_missing_workspace_is_a_refusala prescribed directory nobody made is refused and not invented
owning.rsestablishes
a_named_workspace_is_left_behind_without_its_fifosrun_at lays the session where the caller said and leaves the three bash files and the lock, nothing that was a pipe — the fifo of an announcement that never finished included
a_shell_left_asking_does_not_outlive_the_runthe run does not wait for a straggler, and the straggler does not survive it
a_shell_outside_the_group_is_heard_and_never_signalleda setsid shell is heard, has parted: None, and is alive after the run
a_panicking_answer_kills_the_subjectthe panic propagates out of run, and the blocked subject is gone
malformed.rsestablishes
a_line_cut_short_by_a_shell_that_left_ends_the_runa fork that exits mid-line ends the run naming the line
a_line_cut_short_at_the_end_is_reported_beside_the_subjects_statusthe same left by a shell the session outlived is Run::failed, beside the subject's status
a_line_that_will_not_read_ends_the_run(junk ends the run quoting it
a_frame_the_protocol_did_not_write_ends_the_runa line on the control fifo that is not a frame ends the run quoting it
failing.rsestablishes
a_rig_that_cannot_answer_ends_the_run_and_kills_the_subjectrun yields the rig's reason, and the shell blocked on the ask does not outlive it
a_failure_while_hearing_ends_the_run_and_kills_the_subjectthe same for a message nobody was waiting on, promptly, while another shell asks in a loop

Bash-level invariants that hold without running anything are asserted against the shipped text instead, and live beside it: the protocol's in src/rig/wire/mod.rs, each tool's in its own tests.

Bash constraints that bound the design

The floor is bash 5.0, taken from the changelog rather than measured here: $EPOCHREALTIME, which stamps every message and every account.

Traps do not compose. Bash allows one handler per signal, so contributing an EXIT, ERR or DEBUG fragment means adopting whatever handler the client installed. Provenance and exit are therefore carried by lines and by the kernel rather than by a handler.

A subshell resets caught traps, so anything buffered in a ( … ) and flushed from EXIT is lost. A message is written where it is produced rather than accumulated.

$? must be read as a frame's first statement.

A bash arithmetic command is false when its result is 0, while x=$(( x + n )) has no such status. No instrument in the crate counts in bash.

Under extdebug, a DEBUG handler returning non-zero skips the command it fired for, so the handler must return 0.

Enabling extdebug while BASH_ENV is being read starts the debugger. bashcap's trace arms itself from a DEBUG trap on the next command, which has to be a command of the subject, so its join comes before the trap.

local LC_ALL=C counts bytes, and the subject's locale is back on return. ${#s} and ${s:a:b} count characters in the shell's locale; under LC_ALL=C they count bytes, an assignment to LC_ALL takes effect at once, declare included, and returning restores the outer value, unset included. Measured with and without set -o posix on bash 5.3.9. This is what bounds a frame in bytes.

mkfifo is not a builtin; see above.