From 1ed81b6b46e443c68775abb45005240447db0b63 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 19 Jul 2026 16:21:20 +0200 Subject: [PATCH] perf(runner): unset each file's test functions after processing Test files are sourced into the main shell and their functions stayed defined for the whole run. Every test executes in a $() subshell, so each fork copied an ever-growing shell and multi-file runs slowed down quadratically: bashunit's own unit suite spent ~2s/file on its last files vs 0.3s/file on its first (the last 18 files take 8.7s standalone but ~38s as the tail of the full run). Unset a file's test functions (full pre-tag/rerun list) next to the existing hook cleanup. In parallel mode the file's workers have already forked with their own copy by the time the parent unsets, so it is race-free. unset -f is a builtin: no new forks, Bash 3.0-safe. Unit suite: ~64s -> ~21.5s sequential, ~26s -> ~7s parallel. Full suite: ~2m48s -> ~2m07s sequential, ~55s -> ~24.5s parallel. Closes #829 --- CHANGELOG.md | 1 + src/runner.sh | 23 ++++++++++++++ tests/acceptance/bashunit_fn_cleanup_test.sh | 31 +++++++++++++++++++ .../fixtures/test_fn_cleanup_first.sh | 10 ++++++ .../fixtures/test_fn_cleanup_second.sh | 14 +++++++++ 5 files changed, 79 insertions(+) create mode 100644 tests/acceptance/bashunit_fn_cleanup_test.sh create mode 100644 tests/acceptance/fixtures/test_fn_cleanup_first.sh create mode 100644 tests/acceptance/fixtures/test_fn_cleanup_second.sh 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" +}