Skip to content

Commit 1ed81b6

Browse files
committed
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
1 parent f335e13 commit 1ed81b6

5 files changed

Lines changed: 79 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- `--jobs auto` / `-j auto` caps parallel concurrency at the CPU core count (portable across Linux/macOS/BSD); the default stays unlimited (#766)
1717

1818
### Changed
19+
- 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)
1920
- 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)
2021
- 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)
2122
- `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)

src/runner.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,9 @@ function bashunit::runner::load_test_files() {
430430
filtered_functions=$(bashunit::helper::get_functions_to_run "test" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS")
431431
local functions_for_script
432432
functions_for_script=$(bashunit::runner::functions_for_script "$test_file" "$filtered_functions")
433+
# Full pre-tag/rerun list: these are unset once the file has been
434+
# processed, whatever subset actually runs (#829).
435+
local _script_fns_to_clean="$functions_for_script"
433436
# Apply tag filtering to the early check as well
434437
if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then
435438
bashunit::helper::build_tags_map "$test_file"
@@ -448,6 +451,7 @@ function bashunit::runner::load_test_files() {
448451
functions_for_script=$(bashunit::rerun::filter_functions "$test_file" "$functions_for_script")
449452
fi
450453
if [ -z "$functions_for_script" ]; then
454+
bashunit::runner::clean_script_test_functions "$_script_fns_to_clean"
451455
bashunit::runner::clean_set_up_and_tear_down_after_script
452456
bashunit::runner::restore_workdir
453457
continue
@@ -492,6 +496,7 @@ function bashunit::runner::load_test_files() {
492496
"$exclude_tag_filter" "$_cached_fns"
493497
fi
494498
bashunit::runner::run_tear_down_after_script "$test_file"
499+
bashunit::runner::clean_script_test_functions "$_script_fns_to_clean"
495500
bashunit::runner::clean_set_up_and_tear_down_after_script
496501
if ! bashunit::parallel::is_enabled; then
497502
bashunit::cleanup_script_temp_files
@@ -2066,6 +2071,24 @@ function bashunit::runner::run_tear_down_after_script() {
20662071
return $status
20672072
}
20682073

2074+
##
2075+
# Unset a file's test functions once the file has been processed.
2076+
#
2077+
# Test files are sourced into the main shell, and their functions used to stay
2078+
# defined for the whole run: every test's $() subshell then forked an
2079+
# ever-growing shell, making multi-file runs quadratic in file count (#829).
2080+
# In parallel mode the file's workers have already forked (with their own copy
2081+
# of the functions) by the time this runs, so unsetting here is race-free.
2082+
# Arguments: $1 - whitespace-separated test function names
2083+
##
2084+
function bashunit::runner::clean_script_test_functions() {
2085+
local IFS=$' \t\n'
2086+
local fn
2087+
for fn in $1; do
2088+
unset -f "$fn" 2>/dev/null || true
2089+
done
2090+
}
2091+
20692092
function bashunit::runner::clean_set_up_and_tear_down_after_script() {
20702093
bashunit::internal_log "clean_set_up_and_tear_down_after_script"
20712094
bashunit::helper::unset_if_exists 'set_up'
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Regression test for https://github.com/TypedDevs/bashunit/issues/829
5+
# Test functions must be unset once their file has been processed: they stay
6+
# defined in the main shell otherwise, and every test's $() subshell forks an
7+
# ever-fatter process, making multi-file runs quadratic in file count.
8+
9+
function set_up_before_script() {
10+
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
11+
}
12+
13+
function test_test_functions_are_unset_after_their_file_ran() {
14+
local first_file=./tests/acceptance/fixtures/test_fn_cleanup_first.sh
15+
local second_file=./tests/acceptance/fixtures/test_fn_cleanup_second.sh
16+
17+
local actual
18+
actual="$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$first_file" "$second_file" | strip_ansi)"
19+
20+
assert_contains "2 passed, 2 total" "$actual"
21+
}
22+
23+
function test_test_functions_are_unset_after_their_file_ran_in_parallel() {
24+
local first_file=./tests/acceptance/fixtures/test_fn_cleanup_first.sh
25+
local second_file=./tests/acceptance/fixtures/test_fn_cleanup_second.sh
26+
27+
local actual
28+
actual="$(./bashunit --parallel --env "$TEST_ENV_FILE" "$first_file" "$second_file" | strip_ansi)"
29+
30+
assert_contains "2 passed, 2 total" "$actual"
31+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Regression fixture for https://github.com/TypedDevs/bashunit/issues/829
5+
# First file: defines a marker test function that must not leak into the
6+
# main shell once this file has been processed.
7+
8+
function test_fn_cleanup_marker_from_first_file() {
9+
assert_equals "first" "first"
10+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Regression fixture for https://github.com/TypedDevs/bashunit/issues/829
5+
# Second file: the first file's test functions must already be unset, so the
6+
# main shell does not grow (and slow down every fork) as files accumulate.
7+
8+
function test_previous_file_test_functions_are_unset() {
9+
local defined="no"
10+
if declare -F test_fn_cleanup_marker_from_first_file >/dev/null 2>&1; then
11+
defined="yes"
12+
fi
13+
assert_equals "no" "$defined"
14+
}

0 commit comments

Comments
 (0)