diff --git a/CHANGELOG.md b/CHANGELOG.md index a45f3248..388fe91a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - `--jobs auto` / `-j auto` caps parallel concurrency at the CPU core count (portable across Linux/macOS/BSD); the default stays unlimited (#766) ### Changed +- Multi-file runs are no longer quadratic in file count: each file's test functions are unset once the file has been processed, so test subshells stop forking an ever-growing shell. bashunit's own 63-file unit suite: ~64s -> ~22s sequential, ~26s -> ~7s parallel (#829) - Major performance work with no behaviour change: assertions, per-test execution, per-file discovery, cold start and parallel result publishing are now (near) fork-free, snapshots and `--tag` scans are cached, and quadratic failure rendering is single-pass. Benchmarks on bash 3.2: 100x10 `assert_equals` ~1.50s -> ~0.76s, 500 snapshot assertions ~7.5s -> ~3.0s, 100 tagged tests ~2.92s -> ~0.68s, and bashunit's own acceptance suite ~61s -> ~17s (#761-#764, #772-#775, #798, #801-#807, #809, #810, #813, #817) - Per-test timing now defaults to `auto` (`BASHUNIT_SHOW_EXECUTION_TIME=true|false|auto`): shown only when the clock is fork-free, avoiding `perl` forks on bash 3.2; `--profile`/`--verbose`/reports still measure (see `adrs/adr-008-auto-skip-per-test-timing.md`) (#765) - `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) diff --git a/src/runner.sh b/src/runner.sh index 1a2d914d..bb1b08da 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -430,6 +430,9 @@ function bashunit::runner::load_test_files() { filtered_functions=$(bashunit::helper::get_functions_to_run "test" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") local functions_for_script functions_for_script=$(bashunit::runner::functions_for_script "$test_file" "$filtered_functions") + # Full pre-tag/rerun list: these are unset once the file has been + # processed, whatever subset actually runs (#829). + local _script_fns_to_clean="$functions_for_script" # Apply tag filtering to the early check as well if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then bashunit::helper::build_tags_map "$test_file" @@ -448,6 +451,7 @@ function bashunit::runner::load_test_files() { functions_for_script=$(bashunit::rerun::filter_functions "$test_file" "$functions_for_script") fi if [ -z "$functions_for_script" ]; then + bashunit::runner::clean_script_test_functions "$_script_fns_to_clean" bashunit::runner::clean_set_up_and_tear_down_after_script bashunit::runner::restore_workdir continue @@ -492,6 +496,7 @@ function bashunit::runner::load_test_files() { "$exclude_tag_filter" "$_cached_fns" fi bashunit::runner::run_tear_down_after_script "$test_file" + bashunit::runner::clean_script_test_functions "$_script_fns_to_clean" bashunit::runner::clean_set_up_and_tear_down_after_script if ! bashunit::parallel::is_enabled; then bashunit::cleanup_script_temp_files @@ -2066,6 +2071,24 @@ function bashunit::runner::run_tear_down_after_script() { return $status } +## +# Unset a file's test functions once the file has been processed. +# +# Test files are sourced into the main shell, and their functions used to stay +# defined for the whole run: every test's $() subshell then forked an +# ever-growing shell, making multi-file runs quadratic in file count (#829). +# In parallel mode the file's workers have already forked (with their own copy +# of the functions) by the time this runs, so unsetting here is race-free. +# Arguments: $1 - whitespace-separated test function names +## +function bashunit::runner::clean_script_test_functions() { + local IFS=$' \t\n' + local fn + for fn in $1; do + unset -f "$fn" 2>/dev/null || true + done +} + function bashunit::runner::clean_set_up_and_tear_down_after_script() { bashunit::internal_log "clean_set_up_and_tear_down_after_script" bashunit::helper::unset_if_exists 'set_up' diff --git a/tests/acceptance/bashunit_fn_cleanup_test.sh b/tests/acceptance/bashunit_fn_cleanup_test.sh new file mode 100644 index 00000000..0fa3ce8d --- /dev/null +++ b/tests/acceptance/bashunit_fn_cleanup_test.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regression test for https://github.com/TypedDevs/bashunit/issues/829 +# Test functions must be unset once their file has been processed: they stay +# defined in the main shell otherwise, and every test's $() subshell forks an +# ever-fatter process, making multi-file runs quadratic in file count. + +function set_up_before_script() { + TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" +} + +function test_test_functions_are_unset_after_their_file_ran() { + local first_file=./tests/acceptance/fixtures/test_fn_cleanup_first.sh + local second_file=./tests/acceptance/fixtures/test_fn_cleanup_second.sh + + local actual + actual="$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$first_file" "$second_file" | strip_ansi)" + + assert_contains "2 passed, 2 total" "$actual" +} + +function test_test_functions_are_unset_after_their_file_ran_in_parallel() { + local first_file=./tests/acceptance/fixtures/test_fn_cleanup_first.sh + local second_file=./tests/acceptance/fixtures/test_fn_cleanup_second.sh + + local actual + actual="$(./bashunit --parallel --env "$TEST_ENV_FILE" "$first_file" "$second_file" | strip_ansi)" + + assert_contains "2 passed, 2 total" "$actual" +} diff --git a/tests/acceptance/fixtures/test_fn_cleanup_first.sh b/tests/acceptance/fixtures/test_fn_cleanup_first.sh new file mode 100644 index 00000000..36d477eb --- /dev/null +++ b/tests/acceptance/fixtures/test_fn_cleanup_first.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regression fixture for https://github.com/TypedDevs/bashunit/issues/829 +# First file: defines a marker test function that must not leak into the +# main shell once this file has been processed. + +function test_fn_cleanup_marker_from_first_file() { + assert_equals "first" "first" +} diff --git a/tests/acceptance/fixtures/test_fn_cleanup_second.sh b/tests/acceptance/fixtures/test_fn_cleanup_second.sh new file mode 100644 index 00000000..53707af3 --- /dev/null +++ b/tests/acceptance/fixtures/test_fn_cleanup_second.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regression fixture for https://github.com/TypedDevs/bashunit/issues/829 +# Second file: the first file's test functions must already be unset, so the +# main shell does not grow (and slow down every fork) as files accumulate. + +function test_previous_file_test_functions_are_unset() { + local defined="no" + if declare -F test_fn_cleanup_marker_from_first_file >/dev/null 2>&1; then + defined="yes" + fi + assert_equals "no" "$defined" +}