bash-interop 0.2.0

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.
Documentation
# 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` — this box's, which is uutils in Rust** | **2088** |
| `mkfifo` — GNU coreutils' (`/bin/true` measured 680) or busybox's | ~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](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's words stay the parent's |
| `two_labels_in_one_process_are_two_shells` | two `BC_JOIN`s 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 the subject's, 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.