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.
| chapter | what it covers |
|---|---|
| overview.md | the model in one pass, and the vocabulary |
| design.md | the design decisions the shape follows from |
| rigs.md | Rig, Reacting, Layout, Provision — the API |
| driving.md | Rust orchestrates: run, run_at, the environment closure |
| serving.md | bash orchestrates: serve, the coprocess convention |
| joining.md | every way a shell joins, and who initiates |
| wire.md | the protocol: files, fifos, frames, messages |
| shell.md | a shell's account of itself |
| stack.md | the frame walk, both halves |
| scoping.md | where names bind in the shipped bash |
| measurements.md | the 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 reply | what 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
| term | what it names |
|---|---|
| subject | the bash program under instrumentation: the command line a driven run starts, or the script that started a server |
| shell | one bash process that joined; Shell |
| session | one run: a workspace, a control fifo, a pipe and a task per shell, until the watch fires |
| workspace | the session's directory and address, locked for its life; modelled by Layout |
| label | the bash-side key binding a name to a joined workspace; Rust never sees it |
| rig | your description: definitions, and how a reaction is built; Rig |
| reaction | your per-shell counterpart, run as a task of its own; Reacting |
| message / answer | one arglist a shell shipped / one command a blocked shell runs; Message, Answer |
| account | what a shell says of itself when announcing; becomes Shell |
| kept | what a reaction leaves behind; Reacting::Kept, landing in Attended::kept |
| driving / serving | who started the shells: Rust owns a command line, or a bash script holds the handle; Driving, Serving |
| provision | what a bash_env.bash does about the channel: joins, or only defines; Provision |
| watch | the 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 about | never knows about | |
|---|---|---|
bash-strings (its own crate) | bash's quoted forms — @Q, @A, declare -p | everything else |
shell | bash-strings | how a shell was reached, what it went on to say |
stack | bash-strings, shell | the wire, the rig, any tool |
rig | bash-strings, shell | the 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 installed | a client's trap … EXIT fires as it would unwrapped |
| no builtin shadowed | printf, read, exec mean what they mean |
| no variable exported | nothing leaks into a child that did not join |
no name outside BC_*/__BC_* | a subject's globals cannot collide with ours |
no set -o change | errexit, nounset, pipefail stay as the subject set them |
no eval | nothing the subject wrote is re-parsed |
| its own exit status | a 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 bash | its reading | |
|---|---|---|
bashcap | the walk, plus BASHCAP's effect | one JSON object per snapshot, streamed |
bashprof | the walk, plus BASHPROF_TIMETHIS's effect | three 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 library | what a run produces belongs to the client; Vec<Message> and () are the two shipped |
| a timer, an interval, a heartbeat | serving ends on a descriptor, so tokio's time feature is not enabled |
| a closing word or reserved payload word | the handle says when it is over, and nothing in the loop intercepts a message |
| a way in that the core prefers | every 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 mode | an answer that says no is a command returning non-zero, like any other |
| parallelism | a task per shell on one thread; the cost sits in bash's printf, and a Send bound would tax every implementor |
| a fork tree | a fork inherits and then takes its own pipe; its descent is not reported |
| a schema or IDL | an arglist has no shape to agree on |
See also
rigs.md—Rig,Reacting, and the two roleswire.md— the protocol, line by linemeasurements.md— every number abovescoping.md— where a name binds in the shipped bash
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 reaction | hear | answer | finish |
|---|---|---|---|
Vec<Message> | push | hear it, then Answer::unknown() | Ok(self) |
() | drop it | Answer::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 forbash_strings::emit_scalarwhen 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.
| happening | whose problem, and what follows |
|---|---|
| a rig cannot hear, or cannot decide an answer | the run's: under Driving the subject's process group is killed, and the run returns that Failure |
| an answer that returns non-zero | the 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-written | the 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
Shellknows 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 by | writers | the run holds | the shell holds | |
|---|---|---|---|---|
join | the run, at open | every shell, once | O_RDWR: never end of input | opened, written, closed per attach |
up.<token> | the shell, before it announces | exactly one process | O_RDONLY|O_NONBLOCK, async receiver | exec {fd}> for its life |
rep.<token> | the run, on the announcement | the run | open_sender per answer | exec {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 started | last cell |
|---|---|
| a script file | 0 |
a script file defining a function called main | 0 |
| a file sourced from a script file | 0 |
bash -c '…' | the line the walk was entered from |
| a shell fed on standard input | the same |
| a file sourced from either of those | the 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 | |
|---|---|
main | the top level of the script bash was given |
source | the top level of a file the subject sourced |
in BASH_SOURCE | |
|---|---|
environment | the function came in through the environment (export -f) |
main | the function was defined at an interactive prompt |
$0 | the 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/op | payload bytes | |
|---|---|---|
| assembling rows, with the argument walk | 201 | 522 |
six raw ${arr[*]@Q} expansions | 21 | 314 |
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
- bash-strings: values —
parse_array, the shape each section is - bashcap: bashcap —
extdebug, and what else a snapshot carries - scoping.md — why the nameref rather than a global
- measurements.md — what a snapshot costs
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 X | that caller's frame |
no frame declared X | the 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 parameters | fine — exempt since 4.4 |
"${arr[@]}", "${arr[*]@Q}" on an unset array | fine |
"${arr[0]}", "${#arr[@]}" on an unset array | fatal |
"$1" with no argument | fatal |
${!PREFIX@} with no matches, "${BASH_REMATCH[@]}" unset | fine |
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
returnacts 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 attached | quiet — not POLLHUP, not for 300 ms |
| a writer attaches, no data | still quiet |
| a writer writes | POLLIN |
| a writer writes and exits | POLLIN|POLLHUP, data intact |
| all writers gone, having attached | POLLHUP |
| a non-blocking reader open | unblocks a blocking writer open |
| parent exits, a subshell still holds the inherited fd | POLLIN only — POLLHUP waits for the subshell |
| a reader opens then closes | the 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 write | every line arrives whole |
| each writes 4097 | lines 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_NONBLOCK | quiet 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>up | released by open_receiver, exactly when it was opened |
pipe::OpenOptions::new().open_sender(rep) with no reader | ENXIO immediately |
Sender::write_all of 100 KB to a bash read | completes; bash reads 100 000 bytes |
AsyncFd<pidfd>::readable() | wakes when the process exits |
AsyncFd<read end>::readable() when the writer closes | wakes, is_read_closed |
The whole descriptor layer is stock tokio and nothing is hand-rolled.
What things cost from bash
| µs | |
|---|---|
( : ) — a subshell | 341 |
bash -c ':' | 1471 |
bash -c ':' with a 200-line BASH_ENV | 1884 |
exec {fd}>fifo + close, a reader present | 8 |
printf one message to a fifo | 12 |
mkfifo on this box, which is uutils in Rust | 2088 |
mkfifo from GNU coreutils (/bin/true measured 680) or busybox | ~600 |
a static 800 KB mkfifo — the floor: fork plus a bare exec | 514 |
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 TCP | 41 015 |
over loopback TCP with the receiver re-arming TCP_QUICKACK on every read | 63 |
| over two fifos | 33 |
Cost in bash, per message
Minimum of seven runs of 4000:
| µs | |
|---|---|
| build the message, no I/O | 13.8 |
| write it to the pipe | ~15.5 |
| sending, inlined at every call site | 21 |
| sending through one bash function | 28 |
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 twice | 69.1 |
BC_SAY: one frame, the write shared as an alias | 59.5 |
| a rig's word as a one-command alias over it | 55.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/op | payload bytes | |
|---|---|---|
| rows, with the argument walk in bash | 201 | 522 |
six raw ${arr[*]@Q} expansions | 21 | 314 |
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 measurements | one function | spine of three |
|---|---|---|
| 0 | 349 | 537 |
| 1 | 471 | 1112 |
| 2 | 584 | 1678 |
| 3 | 697 | 2244 |
| 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 bash | 572 µs | 737 µs |
| the walk shipped as columns | 482 µs | 527 µs |
Memory
BashCap decodes and writes in hear, so a snapshot reaches the file as it
arrives. Resident memory does not track the run:
| snapshots | peak RSS | output |
|---|---|---|
| 200 | 7.7 MB | 0.19 MB |
| 2 000 | 7.8 MB | 1.9 MB |
| 20 000 | 7.5 MB | 18.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.rs | establishes |
|---|---|
a_shell_that_speaks_once_and_leaves_loses_nothing | a 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_own | a 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_shells | two BC_JOINs in one rig's bash are two pipes and two shells with one pid |
a_label_nobody_joined_is_an_error_by_absence | a word on an unjoined label names it and the call site, returns 125, and the run knows nothing |
an_account_of_any_size_arrives_whole | a 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_once | 16 shells with 6 KB commands announce together; every account whole and its own |
the_words_a_join_brings_are_on_the_shell | BC_JOIN KEEP <dir> role worker … lands verbatim on Shell::brought, in the fork too; field reads the pairs |
transport.rs | establishes |
|---|---|
every_descendant_shell_reaches_the_run | subshells, command substitutions and child processes are all shells; five of them |
many_shells_at_once_arrive_whole_and_apart | 8 shells × 80 messages, half 9000 bytes, each pipe carries one shell's words |
a_message_of_wide_characters_arrives_whole | 6000 € per message, longer than a pipe's atomic write, character for character |
nothing_is_lost_at_the_end | 200 messages written immediately before exit are read after the subject is gone |
a_newline_inside_a_value_is_escaped_not_a_line | a value containing \n arrives as one word |
transparency.rs | establishes |
|---|---|
a_signalled_subject_is_reported_and_loses_nothing | Signal(15), .shell_code() == 143, and what was said before the signal survives |
a_clients_own_trap_and_ifs_are_untouched | a 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_message | LC_ALL before and after a 9000-byte message |
answering.rs | establishes |
|---|---|
a_session_survives_every_way_of_answering | 57 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_word | an answer awaiting a Notify that another shell's hear triggers completes — serving is concurrent |
starting.rs | establishes |
|---|---|
the_closures_return_is_the_subjects_whole_environment | Rig::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_asked | the run starts the program the argv names, with nothing appended |
a_subject_may_join_by_hand_where_it_chooses | a 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_script | Provision::Definitions: the words in every shell, the channel in none, until the script's own join; the word before it went nowhere |
serving.rs | establishes |
|---|---|
a_shell_that_joined_is_heard_until_it_lets_go | a 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_devices | a client that released the handle while running has parted: None, and its next word takes SIGPIPE |
a_joined_shell_may_publish_to_its_children | the 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_argument | the 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_at | an interactive shell joins by sourcing, and says -i, -s, no command line |
an_occupied_workspace_is_refused | the 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_swept | stale fifos in a prescribed workspace are removed under the lock at open; the session serves and closes clean |
a_missing_workspace_is_a_refusal | a prescribed directory nobody made is refused and not invented |
owning.rs | establishes |
|---|---|
a_named_workspace_is_left_behind_without_its_fifos | run_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_run | the run does not wait for a straggler, and the straggler does not survive it |
a_shell_outside_the_group_is_heard_and_never_signalled | a setsid shell is heard, has parted: None, and is alive after the run |
a_panicking_answer_kills_the_subject | the panic propagates out of run, and the blocked subject is gone |
malformed.rs | establishes |
|---|---|
a_line_cut_short_by_a_shell_that_left_ends_the_run | a fork that exits mid-line ends the run naming the line |
a_line_cut_short_at_the_end_is_reported_beside_the_subjects_status | the 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_run | a line on the control fifo that is not a frame ends the run quoting it |
failing.rs | establishes |
|---|---|
a_rig_that_cannot_answer_ends_the_run_and_kills_the_subject | run 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_subject | the 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.