diff --git a/CHANGELOG.md b/CHANGELOG.md index a28fdb01..ab83dc82 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 test execution: removed the remaining round-2 fork leftovers — `wait_for_job_slot` counts running background jobs without forking `wc` on every poll (145 -> 0 `wc` forks across a small parallel run), and `check_duplicate_functions` folds its per-file grep+awk+sort+uniq scan into a single awk pass. No behaviour change (#761) - `assert_equals`/`assert_same` failures with multiline values now render a git word-diff below the header (requires git, opt out with `BASHUNIT_NO_DIFF=true`, respects `--no-color`); machine reports keep the raw values (#777) - Faster `assert_match_snapshot`: fork-free success path, byte-compatible snapshots; 500 snapshot assertions ~7.5s -> ~3.0s on bash 3.2 (#775) - Faster `--tag`/`--exclude-tag`: per-file `# @tag` annotations scanned once and cached; 100 tagged tests ~2.92s -> ~0.68s on bash 3.2 (#773) diff --git a/src/helpers.sh b/src/helpers.sh index dcb8940e..998d4363 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -213,22 +213,29 @@ function bashunit::helper::check_duplicate_functions() { script="$BASHUNIT_WORKING_DIR/$script" fi - local filtered_lines - filtered_lines=$(grep -E '^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*\s*\(\)\s*\{' "$script") - - local function_names - function_names=$(echo "$filtered_lines" | awk '{ - for (i=1; i<=NF; i++) { - if ($i ~ /^test[a-zA-Z_][a-zA-Z0-9_]*\(\)$/) { - gsub(/\(\)/, "", $i) - print $i - break + # 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). + local duplicates + duplicates=$(awk ' + /^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)[[:space:]]*\{/ { + for (i = 1; i <= NF; i++) { + if ($i ~ /^test[a-zA-Z_][a-zA-Z0-9_]*\(\)$/) { + name = $i + gsub(/\(\)/, "", name) + if (++seen[name] == 2) { + dup[name] = 1 + } + break + } } } - }') - - local duplicates - duplicates=$(echo "$function_names" | sort | uniq -d) + END { + for (name in dup) { + print name + } + } + ' "$script" | sort) 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 866f498a..298352e6 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -294,6 +294,22 @@ function bashunit::runner::_supports_wait_n() { return 1 } +_BASHUNIT_RUNNER_RUNNING_JOBS_OUT=0 + +# Counts running background jobs into _BASHUNIT_RUNNER_RUNNING_JOBS_OUT. `jobs -pr` +# still needs one command substitution, but the line count is pure-bash, so this +# drops the extra `wc` fork per poll iteration on the parallel hot path (#761). +function bashunit::runner::_count_running_jobs() { + local running + running=$(jobs -pr) + if [ -z "$running" ]; then + _BASHUNIT_RUNNER_RUNNING_JOBS_OUT=0 + return + fi + local newlines="${running//[!$'\n']/}" + _BASHUNIT_RUNNER_RUNNING_JOBS_OUT=$((${#newlines} + 1)) +} + function bashunit::runner::wait_for_job_slot() { local max_jobs="${BASHUNIT_PARALLEL_JOBS:-0}" if [ "$max_jobs" -le 0 ]; then @@ -302,8 +318,10 @@ function bashunit::runner::wait_for_job_slot() { if bashunit::runner::_supports_wait_n; then # Bash 4.3+: block until any child exits. No polling, no sleep latency. - while [ "$(jobs -r | wc -l)" -ge "$max_jobs" ]; do + bashunit::runner::_count_running_jobs + while [ "$_BASHUNIT_RUNNER_RUNNING_JOBS_OUT" -ge "$max_jobs" ]; do wait -n 2>/dev/null || break + bashunit::runner::_count_running_jobs done return 0 fi @@ -313,9 +331,8 @@ function bashunit::runner::wait_for_job_slot() { local delay="0.05" local iterations=0 while true; do - local running_jobs - running_jobs=$(jobs -r | wc -l) - if [ "$running_jobs" -lt "$max_jobs" ]; then + bashunit::runner::_count_running_jobs + if [ "$_BASHUNIT_RUNNER_RUNNING_JOBS_OUT" -lt "$max_jobs" ]; then break fi sleep "$delay" diff --git a/tests/unit/fixtures/multiple_duplicate_functions.sh b/tests/unit/fixtures/multiple_duplicate_functions.sh new file mode 100644 index 00000000..517f9fbe --- /dev/null +++ b/tests/unit/fixtures/multiple_duplicate_functions.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 +# shellcheck disable=SC2329 + +function test_beta() { + echo "beta" +} + +function test_alpha() { + echo "alpha" +} + +function test_beta() { + echo "beta duplicate" +} + +test_alpha() { + echo "alpha duplicate without function keyword" +} + +function test_gamma() { + echo "gamma appears once" +} diff --git a/tests/unit/helpers_test.sh b/tests/unit/helpers_test.sh index c57e0a2d..a1b4c410 100644 --- a/tests/unit/helpers_test.sh +++ b/tests/unit/helpers_test.sh @@ -144,6 +144,20 @@ function test_check_duplicate_functions_without_function_keyword() { assert_general_error "$(bashunit::helper::check_duplicate_functions "$file")" } +function test_check_duplicate_functions_reports_each_duplicate_name_sorted() { + local file names + file="$(bashunit::current_dir)/fixtures/multiple_duplicate_functions.sh" + + # Isolate the state mutation in a subshell; report the stored duplicate names. + names=$( + bashunit::helper::check_duplicate_functions "$file" >/dev/null 2>&1 || true + bashunit::state::get_duplicated_function_names + ) + + assert_same "test_alpha +test_beta" "$names" +} + function test_generate_id_uses_pid_suffix_when_not_parallel() { local _orig="${BASHUNIT_PARALLEL_RUN-}" export BASHUNIT_PARALLEL_RUN=false diff --git a/tests/unit/parallel_test.sh b/tests/unit/parallel_test.sh index 28adbfbf..64b37917 100644 --- a/tests/unit/parallel_test.sh +++ b/tests/unit/parallel_test.sh @@ -82,6 +82,32 @@ function test_wait_for_job_slot_releases_when_background_job_finishes() { assert_successful_code "$?" } +function test_count_running_jobs_reflects_started_jobs() { + bashunit::runner::_count_running_jobs + local before=$_BASHUNIT_RUNNER_RUNNING_JOBS_OUT + + sleep 30 & + local p1=$! + sleep 30 & + local p2=$! + + bashunit::runner::_count_running_jobs + local after=$_BASHUNIT_RUNNER_RUNNING_JOBS_OUT + + kill "$p1" "$p2" 2>/dev/null || true + wait "$p1" "$p2" 2>/dev/null || true + + assert_same "2" "$((after - before))" +} + +function test_count_running_jobs_is_zero_with_no_background_jobs() { + # A fresh subshell has no running jobs, so the count must be exactly 0. + local out + out=$(bashunit::runner::_count_running_jobs && echo "$_BASHUNIT_RUNNER_RUNNING_JOBS_OUT") + + assert_same "0" "$out" +} + # === is_enabled tests === function test_parallel_enabled_on_windows() {