Skip to content

Commit dd722c3

Browse files
authored
perf(runner): unset each file's test functions after processing (#830)
1 parent f335e13 commit dd722c3

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)