Overview

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

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

The two sides

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

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

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

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

What a shell says, and what it can be told

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

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

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

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

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

alias STAGE='BC_SAY__ARG_LABEL=DEPLOY BC_SAY STAGE'

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

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

The reply is a command

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

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

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

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

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

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

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

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

Joining: definitions, then initiation

A shell comes to be joined in two steps.

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

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

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

The workspace

After a session opens, its directory looks like this:

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

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

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

One shell, start to finish

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

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

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

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

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

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

How it ends

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

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

What the subject keeps

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

Vocabulary

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

The two tools, and reading on

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

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