From d804aacd5ed5bde4c161298c3a6a82f70f57e499 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 16 Jul 2026 00:28:57 +0200 Subject: [PATCH] perf(runner): read failing-test body once for Source: context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_failure_source_context re-read the test file with a 'sed -n Np' fork for every line of the function body and a 'grep' fork to detect the closing brace, so fork count grew with function length (a 20-line body cost ~46 forks). Read the body in a single bash 'read' loop and detect the closing brace by trimming whitespace — no per-line forks (~46 -> ~2). Output is byte-identical (failure snapshots unchanged). No behaviour change. --- CHANGELOG.md | 1 + src/runner.sh | 29 ++++++++++--------- tests/acceptance/bashunit_run_forks_test.sh | 31 +++++++++++++++++++++ 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7daa2d..c8541f7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,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 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 - Faster test runs: the runner detects a failed source (syntax error / unexpected EOF) and discovery detects the `.bash` pattern variant with shell `case` matches instead of `grep` forks (2 -> 0 `grep` forks per sourced test file). No behaviour change diff --git a/src/runner.sh b/src/runner.sh index fdc67470..1df80bf5 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -1670,27 +1670,30 @@ function bashunit::runner::get_failure_source_context() { local file=$1 local fn_line=$2 - local end_line start_line - end_line=$(wc -l <"$file") - start_line=$((fn_line + 1)) - - local line_text line_num assert_lines="" - line_num=$start_line - while [ "$line_num" -le "$end_line" ]; do - line_text=$(sed -n "${line_num}p" "$file") - # Stop at the closing brace of the function - if [ "$(echo "$line_text" | "$GREP" -cE '^[[:space:]]*\}[[:space:]]*$' || true)" -gt 0 ]; then + # Read the file once (a bash builtin loop) instead of forking `sed` to fetch + # each line and `grep` to test each line for the closing brace. The fork count + # no longer grows with the function length. + local line_text line_num=0 assert_lines="" stripped trimmed + while IFS= read -r line_text || [ -n "$line_text" ]; do + line_num=$((line_num + 1)) + # Skip everything up to and including the function definition line. + if [ "$line_num" -le "$fn_line" ]; then + continue + fi + # Stop at the closing brace of the function (a line that is only `}`). + stripped="${line_text#"${line_text%%[![:space:]]*}"}" + stripped="${stripped%"${stripped##*[![:space:]]}"}" + if [ "$stripped" = "}" ]; then break fi # Collect lines containing assert calls case "$line_text" in *assert_* | *assert\ *) - local trimmed="${line_text#"${line_text%%[![:space:]]*}"}" + trimmed="${line_text#"${line_text%%[![:space:]]*}"}" assert_lines="${assert_lines}\n ${_BASHUNIT_COLOR_FAINT}${line_num}:${_BASHUNIT_COLOR_DEFAULT} ${trimmed}" ;; esac - line_num=$((line_num + 1)) - done + done <"$file" if [ -n "$assert_lines" ]; then echo -e "\n ${_BASHUNIT_COLOR_FAINT}Source:${_BASHUNIT_COLOR_DEFAULT}${assert_lines}" diff --git a/tests/acceptance/bashunit_run_forks_test.sh b/tests/acceptance/bashunit_run_forks_test.sh index 0cf367ad..75a8d592 100644 --- a/tests/acceptance/bashunit_run_forks_test.sh +++ b/tests/acceptance/bashunit_run_forks_test.sh @@ -54,3 +54,34 @@ function test_running_tests_does_not_fork_cat_per_test() { assert_less_or_equal_than 1 "$cat_forks" } + +# Regression guard: rendering the "Source:" assert-line context of a failing +# test must not fork `sed` once per line of the test function's body. The body +# is read in a single pass now, so the `sed` fork count does not grow with the +# function length (a 20-line body used to cost ~20 `sed` forks here). +function test_failure_source_context_does_not_fork_sed_per_line() { + if bashunit::check_os::is_windows; then + bashunit::skip "process tracing is unreliable under Git Bash" && return + fi + + local dir + dir="$(bashunit::temp_dir)" + local fixture="$dir/long_fail_test.sh" + { + echo 'function test_long_fail() {' + local i + for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do + echo " assert_same \"$i\" \"$i\"" + done + echo ' assert_same "expected" "actual"' + echo '}' + } >"$fixture" + + local trace + trace="$(PS4='+ ' bash -x ./bashunit --no-parallel "$fixture" 2>&1 >/dev/null)" + + local sed_forks + sed_forks="$(printf '%s\n' "$trace" | grep -cE '^\++ +/?[a-z/]*sed ' || true)" + + assert_less_than 10 "$sed_forks" +}