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.