From 2051098b340a24c2bbd58cb7d609418ba67bbb62 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 16 Jul 2026 05:25:20 +0200 Subject: [PATCH 1/5] perf(runner): stop forking per test when publishing parallel results parse_result_parallel runs once per test in every parallel worker and forked ~4 times per test: basename for the suite dir name, mkdir -p for a dir that already existed after the first test, and an 'echo | tr | sed' pipeline that sanitized provider args even when there were none. - Derive the suite dir name with parameter expansion. - Pre-create the dir once per file before workers spawn (a [ -d ] check inside a worker races its siblings, so every worker would still pay the fork), and keep a guarded mkdir -p in the worker as fallback. - Skip arg sanitizing when there are no provider args; the pipeline still runs for real provider args. A 10-test parallel run drops from 61 to 21 forks (basename/tr/sed 30 -> 0, mkdir 12 -> 3); the remaining per-test fork is the mktemp that guarantees a unique result file. Parallel is the mode CI runs everything in. No behaviour change. --- CHANGELOG.md | 1 + src/runner.sh | 24 +++++++++-- tests/acceptance/bashunit_run_forks_test.sh | 44 +++++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a392162f..71a037f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - `--jobs auto` / `-j auto` caps parallel concurrency at the CPU core count (portable across Linux/macOS/BSD); the default stays unlimited (#766) ### Changed +- Faster parallel runs: publishing each test's result file no longer forks `basename`, `mkdir` and an `echo | tr | sed` pipeline per test — the suite dir name is parameter expansion, the dir is pre-created once per file before workers spawn, and arg sanitizing is skipped without provider args (a 10-test parallel run dropped from 61 to 21 forks). No behaviour change - Faster test runs: listing all defined functions uses the `compgen -A function` builtin instead of forking `declare -F | awk` (three call sites; 5 -> 3 `awk` forks per test file). No behaviour change - Faster test runs: ordering a file's test functions by definition line is pure bash now instead of an `awk | sort | awk` pipeline that ran twice per file, and the duplicate-function check sorts its (usually empty) result inside awk instead of piping through `sort` (9 -> 5 forks per test file). No behaviour change - Faster failure rendering: the "Source:" assert-line context of a failing test reads the test-function body in a single pass instead of forking `sed` per line and `grep` per line to find the closing brace (a 20-line body dropped from ~46 to ~2 forks here). No behaviour change diff --git a/src/runner.sh b/src/runner.sh index e0572d7a..1a2d914d 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -838,6 +838,14 @@ function bashunit::runner::call_test_functions() { allow_test_parallel=false fi + # Pre-create the file's result dir before spawning test workers: they all + # publish into it, and checking `[ -d ]` inside a worker races its siblings + # (every worker would still pay the mkdir fork). + if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then + local _suite_base="${script##*/}" + mkdir -p "${TEMP_DIR_PARALLEL_TEST_SUITE}/${_suite_base%.sh}" 2>/dev/null || true + fi + for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do if bashunit::parallel::is_enabled && bashunit::parallel::must_stop_on_failure; then break @@ -1560,11 +1568,19 @@ function bashunit::runner::parse_result_parallel() { local -a args args=("$@") - local test_suite_dir="${TEMP_DIR_PARALLEL_TEST_SUITE}/$(basename "$test_file" .sh)" - mkdir -p "$test_suite_dir" + # This runs once per test in every parallel worker, so avoid per-test forks: + # derive the suite dir name with parameter expansion (no basename), only + # mkdir when the dir is missing (first test of the file wins the race, + # `-p` makes the losers no-ops), and skip arg sanitizing entirely for the + # common no-provider-args case. + local test_suite_base="${test_file##*/}" + local test_suite_dir="${TEMP_DIR_PARALLEL_TEST_SUITE}/${test_suite_base%.sh}" + [ -d "$test_suite_dir" ] || mkdir -p "$test_suite_dir" - local sanitized_args - sanitized_args=$(echo "${args[*]+"${args[*]}"}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-|-$//') + local sanitized_args="" + if [ -n "${args[*]+"${args[*]}"}" ]; then + sanitized_args=$(echo "${args[*]}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-|-$//') + fi local template if [ -z "$sanitized_args" ]; then template="${fn_name}.XXXXXX" diff --git a/tests/acceptance/bashunit_run_forks_test.sh b/tests/acceptance/bashunit_run_forks_test.sh index abb39224..0ae05c64 100644 --- a/tests/acceptance/bashunit_run_forks_test.sh +++ b/tests/acceptance/bashunit_run_forks_test.sh @@ -182,3 +182,47 @@ function test_run_removes_its_run_output_dir() { assert_equals 0 "$leftover" } + +# Regression guard for the parallel per-test result path. Publishing each +# test's result file used to fork `basename` (suite dir name), `mkdir -p` +# (suite dir, per test) and an `echo | tr | sed` pipeline (arg sanitizing, even +# with no args) — ~4 forks per test in the mode CI runs everything in. The dir +# name is parameter expansion now, mkdir is guarded by a `[ -d ]` builtin check, +# and arg sanitizing is skipped when there are no provider args. +function test_parallel_result_publishing_does_not_fork_per_test() { + if bashunit::check_os::is_windows; then + bashunit::skip "PATH shims are unreliable under Git Bash" && return + fi + + local dir + dir="$(bashunit::temp_dir)" + local count_file="$dir/count" + local bin + for bin in basename tr sed; do + local real_bin + real_bin="$(command -v "$bin")" + { + echo '#!/usr/bin/env bash' + echo "echo $bin >> \"$count_file\"" + echo "exec \"$real_bin\" \"\$@\"" + } >"$dir/$bin" + chmod +x "$dir/$bin" + done + + local fixture="$dir/parallel_forks_test.sh" + { + echo 'function test_a() { assert_true true; }' + echo 'function test_b() { assert_true true; }' + echo 'function test_c() { assert_true true; }' + echo 'function test_d() { assert_true true; }' + } >"$fixture" + + PATH="$dir:$PATH" ./bashunit --parallel "$fixture" >/dev/null 2>&1 + + local forks=0 + if [ -f "$count_file" ]; then + forks="$(grep -c . "$count_file" || true)" + fi + + assert_equals 0 "$forks" +} From e11eb09234a8ca054e8f59d01080718d1c07f09a Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 16 Jul 2026 05:32:45 +0200 Subject: [PATCH 2/5] test(cli): name the offending binary in the parallel fork census The census failed on Linux CI with a bare count; asserting on the shim log's content makes the failure identify which binary forked and how often. --- tests/acceptance/bashunit_run_forks_test.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/acceptance/bashunit_run_forks_test.sh b/tests/acceptance/bashunit_run_forks_test.sh index 0ae05c64..f8703a56 100644 --- a/tests/acceptance/bashunit_run_forks_test.sh +++ b/tests/acceptance/bashunit_run_forks_test.sh @@ -219,10 +219,12 @@ function test_parallel_result_publishing_does_not_fork_per_test() { PATH="$dir:$PATH" ./bashunit --parallel "$fixture" >/dev/null 2>&1 - local forks=0 + # Assert on the shim log's content, not a count: a failure then names the + # offending binary directly in the test output. + local forked="" if [ -f "$count_file" ]; then - forks="$(grep -c . "$count_file" || true)" + forked="$(sort "$count_file" | uniq -c | tr -d '\n')" fi - assert_equals 0 "$forks" + assert_equals "" "$forked" } From e89292ead56fdebf8443303c716b2aad582ecd62 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 16 Jul 2026 05:44:28 +0200 Subject: [PATCH 3/5] perf(runner): strip ANSI from short strings in pure bash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux CI census caught a per-test sed the macOS profile missed: on systems whose clock is fork-free (Linux date), per-test execution time is shown, and right-aligning it strips the colored line's ANSI codes through 'echo | sed' — one fork per passing test. strip_ansi_to_slot now handles short (<=1KB) backslash-free strings in pure bash: CSI sequences are removed segment-wise with the exact sed semantics (only 'm'/'K' finals swallowed, other finals keep their printable residue), then remaining control bytes are swept with a bounded pattern substitution. Long or backslash-bearing inputs keep the echo -e + sed path, so large assert payloads never hit bash's quadratic substitution. --- CHANGELOG.md | 1 + src/str.sh | 44 ++++++++++++++++++++++++++++++++++++++++++ tests/unit/str_test.sh | 26 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a037f1..a2d42ef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - `--jobs auto` / `-j auto` caps parallel concurrency at the CPU core count (portable across Linux/macOS/BSD); the default stays unlimited (#766) ### Changed +- Faster test runs: stripping ANSI codes from short colored strings is pure bash now, so aligning the per-test execution time (shown on systems with a fork-free clock, e.g. Linux) no longer forks `sed` once per passing test - Faster parallel runs: publishing each test's result file no longer forks `basename`, `mkdir` and an `echo | tr | sed` pipeline per test — the suite dir name is parameter expansion, the dir is pre-created once per file before workers spawn, and arg sanitizing is skipped without provider args (a 10-test parallel run dropped from 61 to 21 forks). No behaviour change - Faster test runs: listing all defined functions uses the `compgen -A function` builtin instead of forking `declare -F | awk` (three call sites; 5 -> 3 `awk` forks per test file). No behaviour change - Faster test runs: ordering a file's test functions by definition line is pure bash now instead of an `awk | sort | awk` pipeline that ran twice per file, and the duplicate-function check sorts its (usually empty) result inside awk instead of piping through `sort` (9 -> 5 forks per test file). No behaviour change diff --git a/src/str.sh b/src/str.sh index 7b828264..47ae94c9 100644 --- a/src/str.sh +++ b/src/str.sh @@ -17,6 +17,50 @@ function bashunit::str::strip_ansi_to_slot() { return ;; esac + # Pure-bash path for short strings without backslashes: display lines (e.g. + # the per-test "✓ Passed … 3ms" alignment on systems whose clock is fork-free) + # land here, so a colored line does not cost a sed fork per test. Strip + # CSI sequences segment-wise, then sweep remaining control bytes; the size + # guard avoids bash's quadratic pattern-substitution on large captures and + # `*\\*` still defers to `echo -e` semantics below. + case "$input" in + *\\*) ;; + *) + if [ "${#input}" -le 1024 ]; then + local out="" rest="$input" params + while :; do + case "$rest" in + *$'\x1b'\[*) + out="$out${rest%%$'\x1b'\[*}" + rest="${rest#*$'\x1b'\[}" + params="" + while :; do + case "$rest" in + [0-9\;]*) + params="$params${rest%"${rest#?}"}" + rest="${rest#?}" + ;; + *) break ;; + esac + done + case "$rest" in + # Same finals sed strips; anything else keeps its printable residue + # (the ESC itself falls to the control-byte sweep, exactly like sed). + m* | K*) rest="${rest#?}" ;; + *) out="$out[$params" ;; + esac + ;; + *) + out="$out$rest" + break + ;; + esac + done + _BASHUNIT_STR_STRIPPED_OUT=${out//[[:cntrl:]]/} + return + fi + ;; + esac _BASHUNIT_STR_STRIPPED_OUT=$(echo -e "$input" | sed -E 's/\x1B\[[0-9;]*[mK]//g; s/[[:cntrl:]]//g') } diff --git a/tests/unit/str_test.sh b/tests/unit/str_test.sh index 8f52c52c..d50371e9 100644 --- a/tests/unit/str_test.sh +++ b/tests/unit/str_test.sh @@ -128,3 +128,29 @@ function test_rpad_width_smaller_than_right_word() { assert_same "... verylongword" "$actual" } + +function test_strip_ansi_to_slot_removes_erase_codes_and_control_chars() { + local input + input="$(printf '\033[2K\033[1;32mok\033[0m\tdone\r')" + + bashunit::str::strip_ansi_to_slot "$input" + + assert_same "okdone" "$_BASHUNIT_STR_STRIPPED_OUT" +} + +function test_strip_ansi_to_slot_long_input_matches_short_path() { + # Inputs beyond the pure-bash size guard take the sed path; both paths must + # produce identical output for the same (repeated) colored payload. + local unit="\033[31mred\033[0m plain " + local long_input="" + local i + for i in $(seq 1 100); do long_input="${long_input}${unit}"; done + long_input="$(printf '%b' "$long_input")" + + local short_expected="" + for i in $(seq 1 100); do short_expected="${short_expected}red plain "; done + + bashunit::str::strip_ansi_to_slot "$long_input" + + assert_same "$short_expected" "$_BASHUNIT_STR_STRIPPED_OUT" +} From daf6c6dfe41c3de00b1a876ba2f680873be7f57b Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 16 Jul 2026 05:55:56 +0200 Subject: [PATCH 4/5] perf(runner): skip the leading-zero sed unless the expression needs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bash 3.0 CI census (no bc in that image) caught math::calculate forking sed on every integer expression to strip leading zeros before $((...)). Clock durations — the common no-bc caller — never contain a leading zero, so a case precheck keeps that path fork-free and only forks sed when a 0-prefixed number is actually present. --- src/math.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/math.sh b/src/math.sh index b0b69462..cc3df1eb 100644 --- a/src/math.sh +++ b/src/math.sh @@ -19,8 +19,14 @@ function bashunit::math::calculate() { ;; esac - # Remove leading zeros from integers - expr=$(echo "$expr" | sed -E 's/\b0*([1-9][0-9]*)/\1/g') + # Remove leading zeros from integers so $((...)) does not read them as octal. + # Only fork sed when a leading zero is actually present — the common callers + # (clock durations) never produce one, so the no-bc path stays fork-free. + case "$expr" in + 0[0-9]* | *[!0-9.]0[0-9]*) + expr=$(echo "$expr" | sed -E 's/\b0*([1-9][0-9]*)/\1/g') + ;; + esac local result=$((expr)) echo "$result" From e3cbeed24b2fa20c1a52021af78c40fd006c8797 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 16 Jul 2026 06:05:22 +0200 Subject: [PATCH 5/5] style: appease CI shellcheck (SC1087 braces, SC2034 loop counter) Brace the expansion adjacent to a literal bracket in the CSI stripper and replace the write-only for-loop counter with a countdown the loop reads; CI runs shellcheck across all files while 'make sa' covers a subset. --- src/str.sh | 2 +- tests/unit/str_test.sh | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/str.sh b/src/str.sh index 47ae94c9..90143eec 100644 --- a/src/str.sh +++ b/src/str.sh @@ -47,7 +47,7 @@ function bashunit::str::strip_ansi_to_slot() { # Same finals sed strips; anything else keeps its printable residue # (the ESC itself falls to the control-byte sweep, exactly like sed). m* | K*) rest="${rest#?}" ;; - *) out="$out[$params" ;; + *) out="${out}[${params}" ;; esac ;; *) diff --git a/tests/unit/str_test.sh b/tests/unit/str_test.sh index d50371e9..3e4547d8 100644 --- a/tests/unit/str_test.sh +++ b/tests/unit/str_test.sh @@ -143,12 +143,14 @@ function test_strip_ansi_to_slot_long_input_matches_short_path() { # produce identical output for the same (repeated) colored payload. local unit="\033[31mred\033[0m plain " local long_input="" - local i - for i in $(seq 1 100); do long_input="${long_input}${unit}"; done - long_input="$(printf '%b' "$long_input")" - local short_expected="" - for i in $(seq 1 100); do short_expected="${short_expected}red plain "; done + local n=100 + while [ "$n" -gt 0 ]; do + long_input="${long_input}${unit}" + short_expected="${short_expected}red plain " + n=$((n - 1)) + done + long_input="$(printf '%b' "$long_input")" bashunit::str::strip_ansi_to_slot "$long_input"