From 1fcd39759ba30176bf8a6e662740901ce03f539e Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 16 Jul 2026 04:51:30 +0200 Subject: [PATCH] perf(runner): order test functions in pure bash, drop 5 forks per file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two per-file fork clusters in discovery became pure bash / awk-internal: - functions_for_script piped 'declare -F' through awk | sort | awk (three forks) and the pipeline ran twice per file. A file's function list is small (tens of names), so filter + insertion-sort by definition line in bash — and enable extdebug only inside the capture subshell so the caller's setting is untouched. - check_duplicate_functions piped its awk pass through 'sort' just to keep the (usually empty) duplicate list deterministic; the awk END block sorts it now. 9 -> 5 forks per test file (awk 7 -> 5, sort 2 -> 0), on every file of every run including the acceptance suite's ~258 nested ones. Measured with a PATH shim: bash -x trace counts also pick up re-echoed test output and overcount. No behaviour change. --- CHANGELOG.md | 1 + src/helpers.sh | 21 +++++++-- src/runner.sh | 42 ++++++++++++++--- tests/acceptance/bashunit_run_forks_test.sh | 38 +++++++++++++++ tests/unit/runner_test.sh | 51 +++++++++++++++++++++ 5 files changed, 143 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33effbd7..b7239cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,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: 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 - Faster test runs: each test's subshell result payload is emitted with `printf` (a builtin) instead of a `cat < 1 `mkdir` forks per cold start). No behaviour change diff --git a/src/helpers.sh b/src/helpers.sh index 8d4cba53..29d9b740 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -215,7 +215,9 @@ function bashunit::helper::check_duplicate_functions() { # One awk pass over the file finds each test-function definition and emits only # the names seen more than once, folding the former grep + awk + sort + uniq - # chain into a single awk + sort (#761). + # chain into a single awk (#761). The END block insertion-sorts the (tiny, + # usually empty) duplicate list itself, so no `sort` fork is needed to keep + # the output deterministic. local duplicates duplicates=$(awk ' /^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)[[:space:]]*\{/ { @@ -231,11 +233,24 @@ function bashunit::helper::check_duplicate_functions() { } } END { + n = 0 for (name in dup) { - print name + names[++n] = name + } + for (i = 2; i <= n; i++) { + v = names[i] + j = i - 1 + while (j >= 1 && names[j] > v) { + names[j + 1] = names[j] + j-- + } + names[j + 1] = v + } + for (i = 1; i <= n; i++) { + print names[i] } } - ' "$script" | sort) + ' "$script") if [ -n "$duplicates" ]; then bashunit::state::set_duplicated_functions_merged "$script" "$duplicates" return 1 diff --git a/src/runner.sh b/src/runner.sh index 1df80bf5..153538e0 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -597,14 +597,42 @@ function bashunit::runner::functions_for_script() { local script="$1" local all_fn_names="$2" - # Filter the names down to the ones defined in the script, sort them by line number - shopt -s extdebug + # Resolve " " for the given names, enabling extdebug only + # inside the capture subshell so the caller's setting is untouched. + local declarations # shellcheck disable=SC2086 - declare -F $all_fn_names | - awk -v s="$script" '$3 == s {print $1" " $2}' | - sort -k2 -n | - awk '{print $1}' - shopt -u extdebug + declarations=$( + shopt -s extdebug + declare -F $all_fn_names 2>/dev/null + ) + + # Keep the functions defined in this script, insertion-sorted by definition + # line. Pure bash: the old `awk | sort | awk` pipeline cost three forks and + # ran twice per file, while a file's function list is small (tens of names). + local -a fns=() + local -a fn_lines=() + local count=0 + local name line file i + while read -r name line file; do + [ "$file" = "$script" ] || continue + i=$count + while [ "$i" -gt 0 ] && [ "${fn_lines[i - 1]}" -gt "$line" ]; do + fns[i]=${fns[i - 1]} + fn_lines[i]=${fn_lines[i - 1]} + i=$((i - 1)) + done + fns[i]=$name + fn_lines[i]=$line + count=$((count + 1)) + done <> \"$count_file\"" + echo "exec \"$real_sort\" \"\$@\"" + } >"$dir/sort" + chmod +x "$dir/sort" + + local fixture="$dir/sort_forks_test.sh" + { + echo 'function test_zz_first() { assert_true true; }' + echo 'function test_aa_second() { assert_true true; }' + } >"$fixture" + + PATH="$dir:$PATH" ./bashunit --no-parallel "$fixture" >/dev/null 2>&1 + + local sort_forks=0 + if [ -f "$count_file" ]; then + sort_forks="$(grep -c . "$count_file" || true)" + fi + + assert_equals 0 "$sort_forks" +} diff --git a/tests/unit/runner_test.sh b/tests/unit/runner_test.sh index 81f89af6..391590c5 100644 --- a/tests/unit/runner_test.sh +++ b/tests/unit/runner_test.sh @@ -285,3 +285,54 @@ function test_supports_reliable_pipefail_matches_bash_version() { bashunit::runner::_supports_reliable_pipefail || actual_rc=$? assert_same "$expected_rc" "$actual_rc" } + +function test_functions_for_script_sorts_by_definition_line() { + local fixture + fixture="$(bashunit::temp_dir)/ffs_order_fixture.sh" + { + echo 'function test_zebra() { :; }' + echo 'function test_alpha() { :; }' + echo 'function test_mid() { :; }' + } >"$fixture" + # shellcheck source=/dev/null + source "$fixture" + + local actual + actual="$(bashunit::runner::functions_for_script "$fixture" "test_alpha test_mid test_zebra")" + + assert_same $'test_zebra\ntest_alpha\ntest_mid' "$actual" +} + +function test_functions_for_script_filters_out_functions_from_other_files() { + local fixture + fixture="$(bashunit::temp_dir)/ffs_filter_fixture.sh" + echo 'function test_only_mine() { :; }' >"$fixture" + # shellcheck source=/dev/null + source "$fixture" + + # test_functions_for_script_filters_out_functions_from_other_files is defined + # in this test file, not in the fixture, so it must be filtered out. + local actual + actual="$(bashunit::runner::functions_for_script "$fixture" \ + "test_only_mine test_functions_for_script_filters_out_functions_from_other_files")" + + assert_same "test_only_mine" "$actual" +} + +function test_functions_for_script_preserves_caller_extdebug_state() { + local fixture + fixture="$(bashunit::temp_dir)/ffs_extdebug_fixture.sh" + echo 'function test_ffs_extdebug() { :; }' >"$fixture" + # shellcheck source=/dev/null + source "$fixture" + + # Toggle inside a subshell so the runner's own shell is never touched. + local state + state=$( + shopt -s extdebug + bashunit::runner::functions_for_script "$fixture" "test_ffs_extdebug" >/dev/null + if shopt -q extdebug; then echo "on"; else echo "off"; fi + ) + + assert_same "on" "$state" +}