Skip to content

Support Bash version-tiered fast paths, so a newer shell can skip a fork the 3.0 floor requires #1352

Description

@Chemaclass

Summary

bashunit supports Bash 3.0, so it forks where a newer shell has a builtin. Today there is no way to ship a faster body for a newer shell, because the compatibility test rejects a too-new construct even inside a branch that Bash 3.0 never takes.

This asks for the mechanism: the runner uses the fastest construct the running shell offers, and falls back per capability down to the 3.0 floor. The floor does not move.

Why the current gates are not it

Five version checks already exist, all runtime predicates re-evaluated on every call:

Site Version Runs
src/runner/context.sh:31-36 _supports_reliable_pipefail 3.1 per test (src/runner/exec.sh:326)
src/assert/core.sh:441-450 _supports_nocasematch 3.1 per assertion
src/runner/parallel.sh:4-14 _supports_wait_n 4.3 per poll
src/coverage/config.sh:128-133 xtrace_is_supported 4.1 once
src/util/clock.sh:5-17 clock impl 5.0 memoized, dispatched per read

Measured per-call overhead above a single ungated definition, best of 5 over 100k calls:

Shape 3.0 4.0 5.2 5.3
Top-level if/else picks the definition once +0.09 -0.05 0.00 -0.07
case on a memoized impl, per call +1.7 +1.2 +0.8 +1.2
Predicate function per call (today's pattern) +17.9 +8.9 +5.0 +4.3

Load-time selection is free. The pattern in use is the slow one. On its own that is 0.1% of a test, so converting the five is not the point; the point is that a load-time gate lets a fast body remove a fork, which is worth 1 to 3 ms.

What is safe to gate, measured

On a real Bash 3.00.22 and 3.2.57, with each construct placed inside if false; then ... fi and inside an uncalled function:

Parse-time. Can never sit behind a gate: &>>, |&, ;;&, ;&, arr+=(x). These kill the file even where they are never reached. arr+=(x) is the trap, since it is a parse error on 3.0 only and parses fine on 3.2.

Runtime-only. Safe inside an untaken branch: ${v,,}, ${v^^}, ${v@Q}, mapfile, declare -A, declare -n, local -n, exec {fd}>, printf -v, x+=y, coproc, wait -n, BASHPID, SRANDOM, EPOCHREALTIME, fractional read -t, ${arr[-1]}, ${ cmd; }.

Dead-code safety did not depend on shape: if false and an uncalled function behaved identically in every case.

One useful corollary. The entrypoint sources every module under set -e, so the Bash 3.0 job already catches every parse-time construct anywhere in src/, whatever the test coverage. The gap the compatibility test closes is exactly the runtime class in untaken branches, and that is the class a gate would contain.

Proposed shape

A flags file declaring one flag per boundary in use, then a column-0 if/else in a leaf module:

# src/system/bash.sh
_BASHUNIT_BASH_GE_40=0
_bashunit_bash_tier=$((${BASH_VERSINFO[0]:-0} * 10 + ${BASH_VERSINFO[1]:-0}))
if [ "$_bashunit_bash_tier" -ge 40 ]; then _BASHUNIT_BASH_GE_40=1; fi
unset _bashunit_bash_tier

# in a leaf module, column 0
if [ "$_BASHUNIT_BASH_GE_40" = 1 ]; then
  function bashunit::str::to_lower_to_slot() { _BASHUNIT_STR_LOWER_OUT=${1,,}; }
else
  function bashunit::str::to_lower_to_slot() { ...tr...; }
fi

The tier lives in the flag name, so the compatibility test can compare a construct's minimum version against the gate's declared tier by text alone.

src/console/colors.sh:32-89 is the existing precedent for a top-level if/else that assigns at load time and survives the build.

Verified against the build

A prototype gate (${input,,} in src/util/str.sh, flags in a new src/system/bash.sh) was taken end to end in a scratch copy of the tree:

  • ./build.sh emitted the block byte-for-byte minus comments.
  • bash -n on the artifact passed on 3.0, 3.2, 4.0, 5.2 and 5.3.
  • The gated helper's tests passed on all five, with tr selected on 3.x and ${input,,} on 4.0+.
  • Full suite against the artifact: 2492 passed, 2 failed, both expected (the compatibility rule this issue is about, and adr_module_table_test.sh wanting its file count bumped for the new file).

The wrong placement was confirmed too. The same block in an index.sh works in dev mode but exits 127 on 5.2 from the artifact while passing on 3.2, because build::process_file (build.sh:94-98) emits an aggregator's body before the files it sources. Invisible on the reference platform; tests/unit/project/build_test.sh:161-171 is what catches it. So the ADR-011 aggregator rule has to be part of the contract.

The compatibility test amendment

Rejected: a trailing pragma such as # bashunit: bash4. It is the author's claim rather than a structural fact, so honouring it is a smuggling path by construction.

Rejected: a per-file allow-list. An ungated construct elsewhere in the same file then ships, which is the gap the test exists to close.

Proposed: recognise the gate block. A construct is allowed only when the offending line sits in the then-branch of a column-0 if [ "$_BASHUNIT_BASH_GE_NN" = 1 ]; then whose NN is at least the construct's minimum, closed by a column-0 else or fi. An indented header is a runtime check and gates nothing. Each rule moves from offenders 'P' to ungated_offenders 'P' TIER, and the parse-time five get tier 0, meaning no gate is ever enough.

A prototype of this is green at 19 tests, covering: allowed inside a matching or higher gate; rejected when ungated, under a lower tier, in the else-branch, or after the gate closes; an indented or non-canonical header gating nothing; the pragma being unable to smuggle; each parse-time construct rejected even inside a _GE_50 gate; malformed and unclosed gates; and a new test_every_src_file_parses_with_the_running_bash, which is trivial on 5.x and meaningful on the macOS 3.2 and real-3.0 jobs.

The rule a gate must obey

A gate may change speed. It may never change observable behavior.

That is not a slogan, and the textbook example breaks it. ${v,,} and tr '[:upper:]' '[:lower:]' disagree on non-ASCII: BSD tr folds ÑÜ, GNU and busybox do not, and ${v,,} folds under C.UTF-8 but not under C. So every gated helper needs an equivalence test over its real input domain, or a documented ASCII-only contract. It also needs a "branch selected matches tier" test, so a gate that silently always falls back to the compat body cannot pass.

CI already exercises both sides: ubuntu, alpine and Windows run 5.x, macOS runs 3.2, and tests-bash-3.0.yml runs a real 3.0.

Scope

Smallest useful slice: the flags file, the compatibility test amendment, the parse test, and an ADR (adrs/adr-013-bash-version-gated-fast-paths.md, shaped like adr-008). Pure safety net, no behavior change.

Then exactly one gated helper, chosen because it removes a fork rather than because a builtin exists.

Two notes for whoever takes this. Real Bash 3.0 rejects :: names in declare -f and declare -F, so gate tests must introspect with type. And build_test.sh:404-417 greps ^function bashunit:: at column 0, so an indented gated definition is invisible to it; it stays green but stops guarding gated functions against a double embed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions