From 6c8b84952848b2597e27e445eb95bce8649ed1c6 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 11 Jul 2026 17:08:29 +0200 Subject: [PATCH 01/10] perf(runner): reuse detected runtime error instead of forking it twice (#764) detect_runtime_error was called once per attempt inside the retry loop and again after it with the same input, each call a $(...) fork. Convert it to the return-slot convention (_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT) and reuse the final attempt's value after the loop, removing both the fork and the duplicate computation from the per-test hot path. No behaviour change. --- src/runner.sh | 23 ++++++++++++++--------- tests/unit/runner_test.sh | 38 ++++++++++++++------------------------ 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/src/runner.sh b/src/runner.sh index 1a86e34d..f097fb1c 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -137,6 +137,7 @@ _BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT=0 _BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT=0 _BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT=0 _BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT=0 +_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="" # Suffix appended to a passed-test line when it only passed after retrying. _BASHUNIT_RETRY_NOTE="" @@ -209,8 +210,13 @@ function bashunit::runner::record_profile() { printf '%s\t%s\t%s\n' "$duration" "$test_name" "$test_file" >>"$PROFILE_OUTPUT_PATH" } +# Writes the detected runtime-error message (empty when none) into +# _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT. Return-slot form avoids a per-test fork +# on the hot path (#764). +# Arguments: $1 runtime_output function bashunit::runner::detect_runtime_error() { local runtime_output=$1 + _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="" case "$runtime_output" in *"command not found"* | *"unbound variable"* | *"permission denied"* | \ *"no such file or directory"* | *"syntax error"* | *"bad substitution"* | \ @@ -222,11 +228,9 @@ function bashunit::runner::detect_runtime_error() { *"too many arguments"* | *"value too great"* | \ *"not a valid identifier"* | *"unexpected EOF"*) local runtime_error="${runtime_output#*: }" - printf '%s' "${runtime_error//$'\n'/}" - return 0 + _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="${runtime_error//$'\n'/}" ;; esac - printf '' } ## @@ -1094,8 +1098,8 @@ function bashunit::runner::run_test() { fi local attempt_runtime_output="${test_execution_result%%##ASSERTIONS_*}" - local attempt_runtime_error - attempt_runtime_error=$(bashunit::runner::detect_runtime_error "$attempt_runtime_output") + bashunit::runner::detect_runtime_error "$attempt_runtime_output" + local attempt_runtime_error=$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT bashunit::runner::extract_result_counts "$test_execution_result" # Mirror the commit-phase failure test exactly (runtime error, non-zero exit, # or a failed assertion); snapshot/incomplete/skipped/risky are not failures. @@ -1139,10 +1143,11 @@ function bashunit::runner::run_test() { fi fi - local runtime_output="${test_execution_result%%##ASSERTIONS_*}" - - local runtime_error - runtime_error=$(bashunit::runner::detect_runtime_error "$runtime_output") + # Reuse the final attempt's values (the loop always runs at least once and + # its locals persist in this function scope), instead of recomputing and + # forking detect_runtime_error a second time (#764). + local runtime_output=$attempt_runtime_output + local runtime_error=$attempt_runtime_error # parse_result accumulates _BASHUNIT_TEST_EXIT_CODE; reset it so each test's # exit code is read in isolation (a non-zero/timed-out test must not poison diff --git a/tests/unit/runner_test.sh b/tests/unit/runner_test.sh index e4c04507..7ab5d111 100644 --- a/tests/unit/runner_test.sh +++ b/tests/unit/runner_test.sh @@ -76,56 +76,46 @@ function test_sync_coverage_flag_sets_zero_when_unset() { } function test_detect_runtime_error_returns_empty_when_input_is_empty() { - local actual - actual="$(bashunit::runner::detect_runtime_error "")" + bashunit::runner::detect_runtime_error "" - assert_empty "$actual" + assert_empty "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" } function test_detect_runtime_error_returns_empty_when_no_known_error() { - local actual - actual="$(bashunit::runner::detect_runtime_error "all good here")" + bashunit::runner::detect_runtime_error "all good here" - assert_empty "$actual" + assert_empty "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" } function test_detect_runtime_error_matches_command_not_found() { - local actual - actual="$(bashunit::runner::detect_runtime_error \ - "script.sh: line 3: foo: command not found")" + bashunit::runner::detect_runtime_error "script.sh: line 3: foo: command not found" - assert_same "line 3: foo: command not found" "$actual" + assert_same "line 3: foo: command not found" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" } function test_detect_runtime_error_matches_syntax_error() { - local actual - actual="$(bashunit::runner::detect_runtime_error \ - "bash: -c: line 1: syntax error near unexpected token")" + bashunit::runner::detect_runtime_error "bash: -c: line 1: syntax error near unexpected token" - assert_same "-c: line 1: syntax error near unexpected token" "$actual" + assert_same "-c: line 1: syntax error near unexpected token" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" } function test_detect_runtime_error_matches_killed() { - local actual - actual="$(bashunit::runner::detect_runtime_error "process: killed")" + bashunit::runner::detect_runtime_error "process: killed" - assert_same "killed" "$actual" + assert_same "killed" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" } function test_detect_runtime_error_strips_newlines_from_extracted_message() { local input=$'bash: line 1: foo: command not found\nextra' - local actual - actual="$(bashunit::runner::detect_runtime_error "$input")" + bashunit::runner::detect_runtime_error "$input" - assert_same "line 1: foo: command not foundextra" "$actual" + assert_same "line 1: foo: command not foundextra" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" } function test_detect_runtime_error_matches_unexpected_eof() { - local actual - actual="$(bashunit::runner::detect_runtime_error \ - "bash: line 5: unexpected EOF while looking for matching")" + bashunit::runner::detect_runtime_error "bash: line 5: unexpected EOF while looking for matching" - assert_same "line 5: unexpected EOF while looking for matching" "$actual" + assert_same "line 5: unexpected EOF while looking for matching" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" } function test_extract_encoded_field_writes_value_to_slot() { From 49c3cf109eddcdd16aa67cf5944806b26bd2d492 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 11 Jul 2026 17:16:26 +0200 Subject: [PATCH 02/10] perf(runner): decode subshell output via return slot, skip fork when empty (#764) decode_subshell_output was wrapped in a $(...) capture on every test. Convert it to the return-slot convention (_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT) and short-circuit the empty/_BASHUNIT_EMPTY_ case inline, so a passing test with no captured output does no subshell fork at all. The non-empty path keeps the inherent base64 decode fork. No behaviour change. --- src/runner.sh | 14 ++++++++++++-- tests/unit/runner_test.sh | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/runner.sh b/src/runner.sh index f097fb1c..b1659788 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -138,6 +138,7 @@ _BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT=0 _BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT=0 _BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT=0 _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="" +_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT="" # Suffix appended to a passed-test line when it only passed after retrying. _BASHUNIT_RETRY_NOTE="" @@ -1131,7 +1132,8 @@ function bashunit::runner::run_test() { "$test_file" "$fn_name" "$duration" "$test_execution_result" fi - local subshell_output=$(bashunit::runner::decode_subshell_output "$test_execution_result") + bashunit::runner::decode_subshell_output "$test_execution_result" + local subshell_output=$_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT if [ -n "$subshell_output" ]; then bashunit::runner::extract_subshell_type "$subshell_output" @@ -1367,12 +1369,20 @@ function bashunit::runner::cleanup_on_exit() { bashunit::state::export_subshell_context } +# Writes the decoded subshell output into _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT. +# The empty case (a passing test with no captured output) short-circuits with +# no subshell at all; only the non-empty path pays the base64 fork (#762/#764). +# Arguments: $1 test_execution_result function bashunit::runner::decode_subshell_output() { local test_execution_result="$1" local test_output_base64="${test_execution_result##*##TEST_OUTPUT=}" test_output_base64="${test_output_base64%%##*}" - bashunit::helper::decode_base64 "$test_output_base64" + if [ -z "$test_output_base64" ] || [ "$test_output_base64" = "_BASHUNIT_EMPTY_" ]; then + _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT="" + return + fi + _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT="$(bashunit::helper::decode_base64 "$test_output_base64")" } function bashunit::runner::is_simple_progress_output() { diff --git a/tests/unit/runner_test.sh b/tests/unit/runner_test.sh index 7ab5d111..81f89af6 100644 --- a/tests/unit/runner_test.sh +++ b/tests/unit/runner_test.sh @@ -118,6 +118,20 @@ function test_detect_runtime_error_matches_unexpected_eof() { assert_same "line 5: unexpected EOF while looking for matching" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" } +function test_decode_subshell_output_writes_empty_for_empty_marker() { + bashunit::runner::decode_subshell_output "pre##TEST_OUTPUT=_BASHUNIT_EMPTY_##ASSERTIONS_PASSED=1" + + assert_empty "$_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT" +} + +function test_decode_subshell_output_decodes_non_empty_output_to_slot() { + local encoded + encoded="$(bashunit::helper::encode_base64 "hello output")" + bashunit::runner::decode_subshell_output "pre##TEST_OUTPUT=${encoded}##ASSERTIONS_PASSED=1" + + assert_same "hello output" "$_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT" +} + function test_extract_encoded_field_writes_value_to_slot() { bashunit::runner::extract_encoded_field \ "preamble##TEST_TITLE=hello world##ASSERTIONS_PASSED=1" "TEST_TITLE" From ae292ff9f7bbe12ee379306c13868fb8d49eb46c Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 11 Jul 2026 17:23:04 +0200 Subject: [PATCH 03/10] perf(helpers): read state globals directly in normalize_test_function_name (#764) normalize_test_function_name forked one or two nested $(...) subshells per call to read the custom title and interpolated-name state via their accessor functions, which only echo reserved-namespace globals. Read the globals directly on this per-test hot path. Accessors are kept for external callers; no behaviour change. --- src/helpers.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/helpers.sh b/src/helpers.sh index 9ee3747e..a8aa42c3 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -41,8 +41,9 @@ function bashunit::helper::normalize_test_function_name() { local original_fn_name="${1-}" local interpolated_fn_name="${2-}" - local custom_title - custom_title="$(bashunit::state::get_test_title)" + # Read the reserved-namespace state globals directly (the accessors just echo + # them) to avoid a nested subshell fork on this per-test hot path (#764). + local custom_title="${_BASHUNIT_TEST_TITLE:-}" if [ -n "$custom_title" ]; then echo "$custom_title" return @@ -51,8 +52,7 @@ function bashunit::helper::normalize_test_function_name() { if [ -z "${interpolated_fn_name-}" ]; then case "${original_fn_name}" in *"::"*) - local state_interpolated_fn_name - state_interpolated_fn_name="$(bashunit::state::get_current_test_interpolated_function_name)" + local state_interpolated_fn_name="${_BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME:-}" if [ -n "$state_interpolated_fn_name" ]; then interpolated_fn_name="$state_interpolated_fn_name" From 5bdc8477a5106202300175e3ecdeb788e67004be Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 11 Jul 2026 17:27:03 +0200 Subject: [PATCH 04/10] perf(helpers): return generate_id via slot to drop the per-test capture fork (#764) generate_id was already internally fork-free but every caller wrapped it in a $(...) capture. Convert it to the return-slot convention (_BASHUNIT_HELPER_ID_OUT); the per-test export_test_identity path and the per-file script-id path now read the slot instead of forking. resolve_test_location already writes a global slot and its single inner subshell is deliberate (it contains the extdebug toggle), so it is left as-is. No behaviour change. --- src/helpers.sh | 8 ++++++-- src/runner.sh | 9 ++++++--- tests/unit/helpers_test.sh | 21 ++++++++++----------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/helpers.sh b/src/helpers.sh index a8aa42c3..4ea6c919 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -549,6 +549,10 @@ function bashunit::helper::get_function_line_number() { echo "$line_number" } +# Writes a sanitized, process-unique id into _BASHUNIT_HELPER_ID_OUT. +# Return-slot form so the per-test caller avoids a $(...) capture fork (#764). +# Arguments: $1 basename +_BASHUNIT_HELPER_ID_OUT="" function bashunit::helper::generate_id() { local basename="$1" # Inline normalize_variable_name + random_str to avoid two forks per call. @@ -565,9 +569,9 @@ function bashunit::helper::generate_id() { for ((_i = 0; _i < 6; _i++)); do _suffix="$_suffix${_chars:RANDOM%${#_chars}:1}" done - echo "${sanitized}_$$_${_suffix}" + _BASHUNIT_HELPER_ID_OUT="${sanitized}_$$_${_suffix}" else - echo "${sanitized}_$$" + _BASHUNIT_HELPER_ID_OUT="${sanitized}_$$" fi } diff --git a/src/runner.sh b/src/runner.sh index b1659788..919d140b 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -50,7 +50,8 @@ function bashunit::runner::source_login_shell_profiles() { function bashunit::runner::export_test_identity() { local test_file=$1 local fn_name=$2 - export BASHUNIT_CURRENT_TEST_ID="$(bashunit::helper::generate_id "$fn_name")" + bashunit::helper::generate_id "$fn_name" + export BASHUNIT_CURRENT_TEST_ID="$_BASHUNIT_HELPER_ID_OUT" bashunit::runner::resolve_test_location "$test_file" "$fn_name" export _BASHUNIT_TEST_LOCATION if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then @@ -369,7 +370,8 @@ function bashunit::runner::load_test_files() { continue fi unset BASHUNIT_CURRENT_TEST_ID - export BASHUNIT_CURRENT_SCRIPT_ID="$(bashunit::helper::generate_id "${test_file}")" + bashunit::helper::generate_id "${test_file}" + export BASHUNIT_CURRENT_SCRIPT_ID="$_BASHUNIT_HELPER_ID_OUT" scripts_ids[scripts_ids_count]="${BASHUNIT_CURRENT_SCRIPT_ID}" scripts_ids_count=$((scripts_ids_count + 1)) bashunit::internal_log "Loading file" "$test_file" @@ -494,7 +496,8 @@ function bashunit::runner::load_bench_files() { for bench_file in "${files[@]+"${files[@]}"}"; do [ -f "$bench_file" ] || continue unset BASHUNIT_CURRENT_TEST_ID - export BASHUNIT_CURRENT_SCRIPT_ID="$(bashunit::helper::generate_id "${bench_file}")" + bashunit::helper::generate_id "${bench_file}" + export BASHUNIT_CURRENT_SCRIPT_ID="$_BASHUNIT_HELPER_ID_OUT" # shellcheck source=/dev/null source "$bench_file" # Update function cache after sourcing new bench file diff --git a/tests/unit/helpers_test.sh b/tests/unit/helpers_test.sh index 8201b65d..cd048a3c 100644 --- a/tests/unit/helpers_test.sh +++ b/tests/unit/helpers_test.sh @@ -131,9 +131,8 @@ function test_generate_id_uses_pid_suffix_when_not_parallel() { local _orig="${BASHUNIT_PARALLEL_RUN-}" export BASHUNIT_PARALLEL_RUN=false - local id - id="$(bashunit::helper::generate_id "test_foo")" - assert_same "test_foo_$$" "$id" + bashunit::helper::generate_id "test_foo" + assert_same "test_foo_$$" "$_BASHUNIT_HELPER_ID_OUT" export BASHUNIT_PARALLEL_RUN="$_orig" } @@ -142,9 +141,10 @@ function test_generate_id_appends_random_suffix_when_parallel() { local _orig="${BASHUNIT_PARALLEL_RUN-}" export BASHUNIT_PARALLEL_RUN=true - local id1 id2 - id1="$(bashunit::helper::generate_id "test_foo")" - id2="$(bashunit::helper::generate_id "test_foo")" + bashunit::helper::generate_id "test_foo" + local id1="$_BASHUNIT_HELPER_ID_OUT" + bashunit::helper::generate_id "test_foo" + local id2="$_BASHUNIT_HELPER_ID_OUT" assert_matches "^test_foo_${$}_[a-zA-Z0-9]{6}$" "$id1" assert_matches "^test_foo_${$}_[a-zA-Z0-9]{6}$" "$id2" @@ -156,12 +156,11 @@ function test_generate_id_sanitizes_basename() { local _orig="${BASHUNIT_PARALLEL_RUN-}" export BASHUNIT_PARALLEL_RUN=false - local id - id="$(bashunit::helper::generate_id "my-file.sh")" - assert_same "my_file_sh_$$" "$id" + bashunit::helper::generate_id "my-file.sh" + assert_same "my_file_sh_$$" "$_BASHUNIT_HELPER_ID_OUT" - id="$(bashunit::helper::generate_id "123start")" - assert_same "_123start_$$" "$id" + bashunit::helper::generate_id "123start" + assert_same "_123start_$$" "$_BASHUNIT_HELPER_ID_OUT" export BASHUNIT_PARALLEL_RUN="$_orig" } From e1714dd824b30e205ca5e6efe9ef1d7e4f06cf14 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 11 Jul 2026 17:30:20 +0200 Subject: [PATCH 05/10] perf(env): resolve retry count in-shell instead of forking it per test (#764) retry_count was captured via $(...) once per test to read a value that is constant across the whole run. Add resolve_retry_count, which validates BASHUNIT_RETRY into the _BASHUNIT_RETRY_VALIDATED global in-shell (no fork); run_test reads the global. retry_count is kept as a thin echoing wrapper for external callers. No behaviour change. --- src/env.sh | 18 ++++++++++++------ src/runner.sh | 4 ++-- tests/unit/env_test.sh | 20 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/env.sh b/src/env.sh index 37331925..92ed30cc 100644 --- a/src/env.sh +++ b/src/env.sh @@ -199,14 +199,20 @@ function bashunit::env::test_timeout_secs() { # Prints the number of extra attempts for a failed test (0 = no retry). # A non-numeric value is treated as 0. ## -function bashunit::env::retry_count() { +# Validates BASHUNIT_RETRY into the integer global _BASHUNIT_RETRY_VALIDATED. +# In-shell (no fork) so the per-test hot path can read the global instead of +# capturing retry_count in a $(...) subshell every test (#764). +_BASHUNIT_RETRY_VALIDATED=0 +function bashunit::env::resolve_retry_count() { case "${BASHUNIT_RETRY:-0}" in - '' | *[!0-9]*) - printf '%s' "0" - return - ;; + '' | *[!0-9]*) _BASHUNIT_RETRY_VALIDATED=0 ;; + *) _BASHUNIT_RETRY_VALIDATED="${BASHUNIT_RETRY:-0}" ;; esac - printf '%s' "${BASHUNIT_RETRY:-0}" +} + +function bashunit::env::retry_count() { + bashunit::env::resolve_retry_count + printf '%s' "$_BASHUNIT_RETRY_VALIDATED" } function bashunit::env::is_random_order_enabled() { diff --git a/src/runner.sh b/src/runner.sh index 919d140b..6f938786 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -1082,8 +1082,8 @@ function bashunit::runner::run_test() { local test_execution_result local timed_out="false" - local retry_max - retry_max=$(bashunit::env::retry_count) + bashunit::env::resolve_retry_count + local retry_max=$_BASHUNIT_RETRY_VALIDATED local retries_used=0 local measure_duration=false bashunit::runner::needs_test_duration && measure_duration=true diff --git a/tests/unit/env_test.sh b/tests/unit/env_test.sh index 43912040..d727cf7d 100644 --- a/tests/unit/env_test.sh +++ b/tests/unit/env_test.sh @@ -188,6 +188,26 @@ function test_retry_count_treats_non_numeric_as_zero() { assert_equals "0" "$result" } +function test_resolve_retry_count_writes_configured_value_to_global() { + local original="${BASHUNIT_RETRY:-0}" + export BASHUNIT_RETRY="3" + + bashunit::env::resolve_retry_count + + export BASHUNIT_RETRY="$original" + assert_equals "3" "$_BASHUNIT_RETRY_VALIDATED" +} + +function test_resolve_retry_count_writes_zero_for_non_numeric_to_global() { + local original="${BASHUNIT_RETRY:-0}" + export BASHUNIT_RETRY="abc" + + bashunit::env::resolve_retry_count + + export BASHUNIT_RETRY="$original" + assert_equals "0" "$_BASHUNIT_RETRY_VALIDATED" +} + function test_is_tap_output_enabled_when_format_is_tap() { local original="$BASHUNIT_OUTPUT_FORMAT" export BASHUNIT_OUTPUT_FORMAT="tap" From c4bb00786c43f91a0e63b2022e57536b1b54f942 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 11 Jul 2026 17:32:30 +0200 Subject: [PATCH 06/10] perf(console): build passing-test line with pure-bash concat, no printf fork (#764) print_successful_test wrapped a $(printf ...) on every passing test to build its output line, though the format only did %s substitution. Concatenate the line directly (byte-identical, covered by the args and no-args console_results tests). No behaviour change. --- src/console_results.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/console_results.sh b/src/console_results.sh index f4f99b36..b0cd786d 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -227,9 +227,11 @@ function bashunit::console_results::print_successful_test() { local duration=${1:-"0"} shift + # Pure-bash concatenation (the printf only did %s substitution) to avoid a + # $(...) fork per passing test (#764). local line if [ -z "$*" ]; then - line=$(printf "%sāœ“ Passed%s: %s" "$_BASHUNIT_COLOR_PASSED" "$_BASHUNIT_COLOR_DEFAULT" "$test_name") + line="${_BASHUNIT_COLOR_PASSED}āœ“ Passed${_BASHUNIT_COLOR_DEFAULT}: ${test_name}" else local quoted_args="" local arg @@ -240,8 +242,7 @@ function bashunit::console_results::print_successful_test() { quoted_args="$quoted_args, '$arg'" fi done - line=$(printf "%sāœ“ Passed%s: %s (%s)" \ - "$_BASHUNIT_COLOR_PASSED" "$_BASHUNIT_COLOR_DEFAULT" "$test_name" "$quoted_args") + line="${_BASHUNIT_COLOR_PASSED}āœ“ Passed${_BASHUNIT_COLOR_DEFAULT}: ${test_name} (${quoted_args})" fi # Retry annotation (e.g. " (retry 1/2)") set by the runner when a test only From ae911c3e29b0b1825d987072bdd72f1339c0bb52 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 11 Jul 2026 17:41:39 +0200 Subject: [PATCH 07/10] perf(globals): skip the per-test temp-file rm fork when nothing was created (#764) cleanup_testcase_temp_files ran rm -rf on every test's temp-file glob even though most tests create none, forking rm once per test (100/100 on the noop census). Probe the glob in pure bash first (a non-matching glob stays literal with nullglob off, so matches[0] does not exist) and rm only when a file matched. A global flag cannot be used here: temp_file/temp_dir run in a $(...) capture subshell, so a flag set there never reaches the EXIT-trap cleanup; the filesystem is the only reliable signal. Fork census on the 100-noop file: rm 102 -> 2. No behaviour change. --- src/globals.sh | 9 ++++++++- tests/unit/globals_test.sh | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/globals.sh b/src/globals.sh index eca91157..4461aa96 100644 --- a/src/globals.sh +++ b/src/globals.sh @@ -67,7 +67,14 @@ function bashunit::temp_dir() { function bashunit::cleanup_testcase_temp_files() { bashunit::internal_log "cleanup_testcase_temp_files" if [ -n "${BASHUNIT_CURRENT_TEST_ID:-}" ]; then - rm -rf "$BASHUNIT_TEMP_DIR/${BASHUNIT_CURRENT_TEST_ID}"_* + # Probe the glob in pure bash first: most tests create no temp file, so + # skipping the rm avoids a fork per test (#764). A non-matching glob either + # stays literal (nullglob off) or yields an empty array (nullglob on); + # ${matches[0]:-} handles both under set -u, and [ -e ] is false in each + # case. temp_file runs in a $(...) subshell, so a global flag could not + # reach this trap; checking the filesystem is the only reliable signal. + local matches=("$BASHUNIT_TEMP_DIR/${BASHUNIT_CURRENT_TEST_ID}"_*) + [ -e "${matches[0]:-}" ] && rm -rf "${matches[@]}" fi } diff --git a/tests/unit/globals_test.sh b/tests/unit/globals_test.sh index 05e9f1a7..bbcbcbbc 100644 --- a/tests/unit/globals_test.sh +++ b/tests/unit/globals_test.sh @@ -79,6 +79,35 @@ function test_globals_temp_dir_in_test_function() { assert_directory_not_exists "$temp_dir" } +function test_globals_cleanup_testcase_skips_and_preserves_others_when_none_created() { + local original_id="${BASHUNIT_CURRENT_TEST_ID:-}" + # A test id with no temp files of its own exercises the pure-bash skip path. + export BASHUNIT_CURRENT_TEST_ID="ghost_${$}_noexist" + local unrelated="$BASHUNIT_TEMP_DIR/unrelated_${$}.keep" + : >"$unrelated" + + bashunit::cleanup_testcase_temp_files + + assert_file_exists "$unrelated" + rm -f "$unrelated" + export BASHUNIT_CURRENT_TEST_ID="$original_id" +} + +function test_globals_cleanup_testcase_skip_path_is_safe_under_nullglob() { + local original_id="${BASHUNIT_CURRENT_TEST_ID:-}" + export BASHUNIT_CURRENT_TEST_ID="ghost_${$}_noexist" + + # With nullglob on the non-matching glob yields an empty array; the skip must + # not trip set -u on ${matches[0]}. + shopt -s nullglob + bashunit::cleanup_testcase_temp_files + local status=$? + shopt -u nullglob + + assert_successful_code "$status" + export BASHUNIT_CURRENT_TEST_ID="$original_id" +} + function test_globals_temp_dir_and_file_in_script() { assert_directory_exists "$SCRIPT_TEMP_DIR" assert_file_exists "$SCRIPT_TEMP_FILE" From 03c3e478fab43955f02ed4f9bd6c23450fd1cc2f Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 11 Jul 2026 17:48:04 +0200 Subject: [PATCH 08/10] docs: changelog for per-test subshell-fork removal (#764) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d4ca3d..77ea68af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Changed +- Faster test execution: removed avoidable subshell forks from the per-test hot path (runtime-error detection, subshell-output decode, name normalization, test-id generation, retry-count lookup, and the passing-test line are now fork-free), and per-test temp-file cleanup no longer forks `rm` when the test created none (`rm` 102 -> 2 on a 100-test file). No behaviour change (#764) - Per-test execution time now defaults to `auto` (`BASHUNIT_SHOW_EXECUTION_TIME=true|false|auto`): shown when the shell has a fork-free clock (Bash 5.0+, GNU `date`), hidden when measuring would fork an interpreter (e.g. Bash 3.2 on macOS, which falls back to `perl`). This removes ~2 `perl` forks per test on those shells. `--profile`, `--verbose`, reports, and `=true` still measure. See `adrs/adr-008-auto-skip-per-test-timing.md` (#765) - Faster test execution: each test file's data-provider annotations are scanned once and cached, replacing a per-test `grep`+`sed` probe with a pure-bash lookup (no behaviour change) (#763) - Faster test execution: skip the base64 encode fork for a test's empty title, hook message and output (the common case), and short-circuit decoding of empty fields (no behaviour change) (#762) From dbcef4fad1c13a9a7f264be11640041a3af5d01c Mon Sep 17 00:00:00 2001 From: JesusValera Date: Sat, 11 Jul 2026 21:49:30 +0200 Subject: [PATCH 09/10] fix(globals): expand the temp-file cleanup glob on bash 3.0 (#764) bash 3.0 does not expand a compound array assignment attached to `local`: `local matches=(glob)` stores the literal string "(glob)" instead of the matched paths. The cleanup probe therefore never saw a match, [ -e ] was false, and per-test temp files were left behind, failing the two temp file/dir tests on the Bash 3.0 jobs while every newer bash passed. Declare and assign separately so the glob expands on 3.0 too. --- src/globals.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/globals.sh b/src/globals.sh index 4461aa96..cadee505 100644 --- a/src/globals.sh +++ b/src/globals.sh @@ -73,7 +73,10 @@ function bashunit::cleanup_testcase_temp_files() { # ${matches[0]:-} handles both under set -u, and [ -e ] is false in each # case. temp_file runs in a $(...) subshell, so a global flag could not # reach this trap; checking the filesystem is the only reliable signal. - local matches=("$BASHUNIT_TEMP_DIR/${BASHUNIT_CURRENT_TEST_ID}"_*) + # Declare and assign separately: bash 3.0 does not expand a compound array + # assignment attached to `local`, it stores the literal "(glob)" instead. + local matches + matches=("$BASHUNIT_TEMP_DIR/${BASHUNIT_CURRENT_TEST_ID}"_*) [ -e "${matches[0]:-}" ] && rm -rf "${matches[@]}" fi } From 1996caea90ba8c57a463176db02a37aa9bcea989 Mon Sep 17 00:00:00 2001 From: JesusValera Date: Sat, 11 Jul 2026 22:14:34 +0200 Subject: [PATCH 10/10] fix(compat): expand compound array assignments on bash 3.0 (#764) bash 3.0 does not expand a compound array assignment attached to `local`. watch.sh collapsed "$@" into a single literal element "(a b c)", so watch mode forwarded its extra args as one broken argument; console_results.sh stored the literal "()" as element 0 instead of building an empty array. Both are the same defect just fixed in globals.sh, and both are silent on every bash >= 3.2. Declare and assign separately in each, and add a guard test that greps src/ for the pattern, since a behavioural test could only fail under bash 3.0. --- src/console_results.sh | 6 +++++- src/watch.sh | 5 ++++- tests/unit/bash_compatibility_test.sh | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 tests/unit/bash_compatibility_test.sh diff --git a/src/console_results.sh b/src/console_results.sh index b0cd786d..67a74c23 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -372,7 +372,11 @@ function bashunit::console_results::snapshot_line_diff() { # Explicit empty-array init so referencing the arrays is safe under `set -u` # on Bash 4.4+ (Bash 3.x is lenient; newer Bash treats an unset array as unbound). - local expected_lines=() actual_lines=() + # Declare and assign separately: bash 3.0 does not expand a compound array + # assignment attached to `local`, it stores the literal "()" as element 0. + local expected_lines actual_lines + expected_lines=() + actual_lines=() local _line="" local i=0 while IFS= read -r _line || [ -n "$_line" ]; do diff --git a/src/watch.sh b/src/watch.sh index a5269c0e..7e8c8b36 100644 --- a/src/watch.sh +++ b/src/watch.sh @@ -21,7 +21,10 @@ function bashunit::watch::is_available() { function bashunit::watch::run() { local path="${1:-.}" shift - local extra_args=("$@") + # Declare and assign separately: bash 3.0 does not expand a compound array + # assignment attached to `local`, it collapses "$@" into one literal element. + local extra_args + extra_args=("$@") local tool tool=$(bashunit::watch::is_available) diff --git a/tests/unit/bash_compatibility_test.sh b/tests/unit/bash_compatibility_test.sh new file mode 100644 index 00000000..d5d05998 --- /dev/null +++ b/tests/unit/bash_compatibility_test.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +# Bash 3.0 does not expand a compound array assignment attached to `local`: +# `local arr=(a b)` stores the literal string "(a b)" as a single element +# instead of building the array. Every bash >= 3.2 does the right thing, so +# this only ever breaks on the Bash 3.0 jobs, and silently (see #764). +# Declare and assign on separate lines instead: +# +# local arr +# arr=(a b) +# +function test_src_has_no_compound_array_assignment_attached_to_local() { + local offenders + offenders=$(grep -rnE '^[[:space:]]*local[[:space:]]+[A-Za-z_][A-Za-z0-9_]*=\(' src/ || true) + + assert_empty "$offenders" +}