Measurements and limits

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

The kernel, on fifos

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

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

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

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

tokio, on the same

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

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

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

What things cost from bash

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

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

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

The token

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

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

Loopback TCP, measured and rejected

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

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

Cost in bash, per message

Minimum of seven runs of 4000:

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

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

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

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

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

The frame walk

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

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

See stack.md.

What a function layer costs an instrument

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

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

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

What a callee's frame gives back

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

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

Cost of a snapshot

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

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

Memory

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

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

What the proofs establish

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

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

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

Bash constraints that bound the design

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

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

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

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

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

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

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

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

mkfifo is not a builtin; see above.