Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

### Changed
- Sequential multi-file runs reuse provider and annotation metadata from header counting for unchanged files up to 512 characters (#1372)
- Parallel reports collect worker results without per-row base64 processes, preserving values and completion order (#1371)
- Data providers pass arguments without per-argument base64 processes, preserving quoting, empty values and parser isolation (#1370)
- JSON reports write ordinary filenames, test names and empty messages without per-field processes, preserving existing escaping (#1369)
Expand Down
21 changes: 17 additions & 4 deletions src/helper/discovery.sh
Original file line number Diff line number Diff line change
Expand Up @@ -279,15 +279,23 @@ function bashunit::helper::find_total_tests() {
local total_count=0
local file

if [ "$#" -gt 1 ]; then
_BASHUNIT_PROVIDER_HEADER_CACHE_PATHS=()
_BASHUNIT_PROVIDER_HEADER_CACHE_CONTENTS=()
_BASHUNIT_PROVIDER_HEADER_CACHE_ROWS=()
_BASHUNIT_PROVIDER_HEADER_CACHE_NEXT=0
# Parallel file workers inherit separate cache cursors.
if [ "${_BASHUNIT_PARALLEL_ENABLED:-false}" != true ]; then
_BASHUNIT_PROVIDER_HEADER_CACHE_SEEDING=true
fi
fi

for file in "$@"; do
if [ ! -f "$file" ]; then
continue
fi

# Build the provider map in THIS shell before the counting subshell: the
# subshell inherits it (its own build call becomes a cache hit), and when
# the caller runs in the main shell the runner's later build for the same
# file is a cache hit too — one awk scan per file instead of two.
# Build in this shell so the isolated counting path inherits the metadata.
bashunit::helper::build_provider_map "$file"

if bashunit::helper::_can_count_statically; then
Expand All @@ -298,6 +306,11 @@ function bashunit::helper::find_total_tests() {
total_count=$((total_count + _BASHUNIT_HELPER_FILE_COUNT_OUT))
done

_BASHUNIT_PROVIDER_HEADER_CACHE_SEEDING=false
if [ "${#_BASHUNIT_PROVIDER_HEADER_CACHE_PATHS[@]}" -gt 0 ]; then
_BASHUNIT_PROVIDER_MAP_SCRIPT=""
fi

_BASHUNIT_HELPER_TOTAL_TESTS_OUT=$total_count
echo "$total_count"
}
Expand Down
144 changes: 96 additions & 48 deletions src/helper/provider.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

# @data_provider resolution and the per-script provider map.

_BASHUNIT_PROVIDER_HEADER_CACHE_SEEDING=false
_BASHUNIT_PROVIDER_HEADER_CACHE_PATHS=()
_BASHUNIT_PROVIDER_HEADER_CACHE_CONTENTS=()
_BASHUNIT_PROVIDER_HEADER_CACHE_ROWS=()
_BASHUNIT_PROVIDER_HEADER_CACHE_NEXT=0
_BASHUNIT_PROVIDER_SHORT_CONTENT_OUT=""

# EOF completes the snapshot; a NUL or the size limit makes it unusable.
function bashunit::helper::_read_short_provider_script() {
_BASHUNIT_PROVIDER_SHORT_CONTENT_OUT=""
[ -r "$1" ] || return 1
IFS= read -r -n 513 -d '' _BASHUNIT_PROVIDER_SHORT_CONTENT_OUT 2>/dev/null <"$1" && return 1
return 0
}

#
# Resolves a script path, applying the issue #529 working-dir fallback.
# Writes the resolved path into _BASHUNIT_PROVIDER_RESOLVED_OUT (empty if unreadable).
Expand All @@ -22,8 +37,7 @@ function bashunit::helper::_resolve_provider_script() {


#
# Scans a script once and caches its test-function -> provider-function pairs.
# Memoized by resolved path, so repeated calls for the same file do not rescan.
# Scans provider pairs, annotations and test discovery metadata for the current script.
# Arguments: $1 - path to the test script
#
function bashunit::helper::build_provider_map() {
Expand All @@ -45,6 +59,38 @@ function bashunit::helper::build_provider_map() {
return
fi

local scan_rows="" cache_index="" index cache_size=${#_BASHUNIT_PROVIDER_HEADER_CACHE_PATHS[@]}
if [ "$_BASHUNIT_PROVIDER_HEADER_CACHE_SEEDING" != true ] && [ "$cache_size" -gt 0 ]; then
index=$_BASHUNIT_PROVIDER_HEADER_CACHE_NEXT
if [ "${_BASHUNIT_PROVIDER_HEADER_CACHE_PATHS[index]:-}" != "$script" ]; then
index=""
local candidate
for candidate in "${!_BASHUNIT_PROVIDER_HEADER_CACHE_PATHS[@]}"; do
if [ "${_BASHUNIT_PROVIDER_HEADER_CACHE_PATHS[candidate]}" = "$script" ]; then
index=$candidate
break
fi
done
fi
if [ -n "$index" ]; then
if bashunit::helper::_read_short_provider_script "$script" &&
[ "$_BASHUNIT_PROVIDER_SHORT_CONTENT_OUT" = "${_BASHUNIT_PROVIDER_HEADER_CACHE_CONTENTS[index]}" ]; then
scan_rows="${_BASHUNIT_PROVIDER_HEADER_CACHE_ROWS[index]}"
cache_index=$index
fi
_BASHUNIT_PROVIDER_HEADER_CACHE_PATHS[index]=""
while [ "$_BASHUNIT_PROVIDER_HEADER_CACHE_NEXT" -lt "$cache_size" ] &&
[ -z "${_BASHUNIT_PROVIDER_HEADER_CACHE_PATHS[_BASHUNIT_PROVIDER_HEADER_CACHE_NEXT]:-}" ]; do
_BASHUNIT_PROVIDER_HEADER_CACHE_NEXT=$((_BASHUNIT_PROVIDER_HEADER_CACHE_NEXT + 1))
done
if [ "$_BASHUNIT_PROVIDER_HEADER_CACHE_NEXT" -eq "$cache_size" ]; then
_BASHUNIT_PROVIDER_HEADER_CACHE_PATHS=()
_BASHUNIT_PROVIDER_HEADER_CACHE_CONTENTS=()
_BASHUNIT_PROVIDER_HEADER_CACHE_ROWS=()
fi
fi
fi

_BASHUNIT_PROVIDER_MAP_SCRIPT="$script"
_BASHUNIT_PROVIDER_MAP_FNS=()
_BASHUNIT_PROVIDER_MAP_PROVIDERS=()
Expand All @@ -56,51 +102,14 @@ function bashunit::helper::build_provider_map() {

local count=0
local fn provider annot_timeout annot_retry annot_skip annot_reason
# Single awk pass emits "<fn>\t<provider>" for every function whose
# definition is at most two lines below a `# @data_provider` (or
# `# data_provider`) annotation, mirroring the previous grep -B2 + sed.
# A reserved sentinel fn name carries the no-parallel-tests flag out of the
# single awk pass; real fn names are identifiers so they never collide.
#
# The per-test `# @timeout` / `# @retry` / `# @skip` markers ride on this same
# pass, as "@@annot@@" rows: this scan already visits every file exactly once
# in the main shell, and the fork budget leaves no room for a second awk per
# file (#773). Unlike the provider marker, those follow the `# @tag` rule --
# the contiguous comment block directly above the definition.
#
# Fed by a here-string rather than `< <(awk …)`. Bash 3.x only reaps the
# descriptors process substitution allocates when it returns to the top level
# or after forking an external command, and this scan runs once per file from
# inside a function that the run never leaves -- so each file leaked one, and
# the per-file `rm` fork that used to follow was the only thing collecting
# them. Removing that fork for #1271 turned the leak loose: with 120 files
# under a 120-descriptor limit the run stopped after two assertions and
# reported "risky" rather than failing. A here-string allocates nothing.
while IFS=$'\t' read -r fn provider annot_timeout annot_retry annot_skip annot_reason; do
[ -z "$fn" ] && continue
if [ "$fn" = "@@no_parallel@@" ]; then
[ "$provider" = "1" ] && _BASHUNIT_PROVIDER_MAP_NO_PARALLEL=true
continue
fi
if [ "$fn" = "@@testfns@@" ]; then
_BASHUNIT_PROVIDER_MAP_TEST_FNS="$provider"
continue
fi
if [ "$fn" = "@@dynamic@@" ]; then
_BASHUNIT_PROVIDER_MAP_DYNAMIC=true
continue
if [ -z "$cache_index" ]; then
local before_content="" can_cache=false
if [ "$_BASHUNIT_PROVIDER_HEADER_CACHE_SEEDING" = true ] &&
bashunit::helper::_read_short_provider_script "$script"; then
before_content=$_BASHUNIT_PROVIDER_SHORT_CONTENT_OUT
can_cache=true
fi
if [ "$fn" = "@@annot@@" ]; then
[ "$annot_timeout" = "@@none@@" ] && annot_timeout=""
[ "$annot_retry" = "@@none@@" ] && annot_retry=""
bashunit::helper::annotations_record \
"$provider" "$annot_timeout" "$annot_retry" "$annot_skip" "$annot_reason"
continue
fi
_BASHUNIT_PROVIDER_MAP_FNS[count]="$fn"
_BASHUNIT_PROVIDER_MAP_PROVIDERS[count]="$provider"
count=$((count + 1))
done <<<"$(awk '
local awk_program='
/^# bashunit: no-parallel-tests/ { no_parallel = 1; next }
/^[[:space:]]*#[[:space:]]*@?data_provider[[:space:]]+/ {
p = $0
Expand Down Expand Up @@ -216,7 +225,47 @@ function bashunit::helper::build_provider_map() {
if (dynamic) { printf "@@dynamic@@\t1\n" }
else { printf "@@testfns@@\t%s\n", top_level_fns }
}
' "$script" 2>/dev/null)"
'
if [ "$can_cache" = true ]; then
# The scanner must see the same bytes the cache later validates.
scan_rows=$(awk "$awk_program" 2>/dev/null <<<"$before_content") || can_cache=false
else
scan_rows=$(awk "$awk_program" "$script" 2>/dev/null)
fi
if [ "$can_cache" = true ]; then
index=${#_BASHUNIT_PROVIDER_HEADER_CACHE_PATHS[@]}
_BASHUNIT_PROVIDER_HEADER_CACHE_PATHS[index]="$script"
_BASHUNIT_PROVIDER_HEADER_CACHE_CONTENTS[index]="$before_content"
_BASHUNIT_PROVIDER_HEADER_CACHE_ROWS[index]="$scan_rows"
fi
fi

# A here-string avoids the descriptor leak from process substitution on Bash 3.
while IFS=$'\t' read -r fn provider annot_timeout annot_retry annot_skip annot_reason; do
[ -z "$fn" ] && continue
if [ "$fn" = "@@no_parallel@@" ]; then
[ "$provider" = "1" ] && _BASHUNIT_PROVIDER_MAP_NO_PARALLEL=true
continue
fi
if [ "$fn" = "@@testfns@@" ]; then
_BASHUNIT_PROVIDER_MAP_TEST_FNS="$provider"
continue
fi
if [ "$fn" = "@@dynamic@@" ]; then
_BASHUNIT_PROVIDER_MAP_DYNAMIC=true
continue
fi
if [ "$fn" = "@@annot@@" ]; then
[ "$annot_timeout" = "@@none@@" ] && annot_timeout=""
[ "$annot_retry" = "@@none@@" ] && annot_retry=""
bashunit::helper::annotations_record \
"$provider" "$annot_timeout" "$annot_retry" "$annot_skip" "$annot_reason"
continue
fi
_BASHUNIT_PROVIDER_MAP_FNS[count]="$fn"
_BASHUNIT_PROVIDER_MAP_PROVIDERS[count]="$provider"
count=$((count + 1))
done <<<"$scan_rows"
}


Expand Down Expand Up @@ -251,4 +300,3 @@ function bashunit::helper::get_provider_data() {
bashunit::helper::execute_function_if_exists "$_BASHUNIT_PROVIDER_FN_OUT"
fi
}

70 changes: 66 additions & 4 deletions tests/acceptance/bashunit_run_forks_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,72 @@ function test_running_a_test_file_stays_within_the_awk_fork_budget() {
assert_less_or_equal_than 2 "$awk_forks"
}

# Regression guard: a run must remove its run-output scratch directory on exit.
# The directory (introduced in #801 under $TMPDIR/bashunit/run/) had no cleanup,
# leaking one empty directory per bashunit invocation. Point the nested run at a
# private TMPDIR so the check is deterministic and parallel-safe.
# Each mode needs its own temp owner when the acceptance suite runs in parallel.
function _assert_multifile_metadata_scan_budget() {
if bashunit::check_os::is_windows; then
bashunit::skip "PATH shims are unreliable under Git Bash" && return
fi

local mode=$1 expected=$2 dir real_awk count_file file
dir="$(bashunit::temp_dir)"
count_file="$dir/awk_count"
: >"$count_file"
real_awk="$(command -v awk)"
{
printf '%s\n' '#!/usr/bin/env bash'
printf 'printf "awk\\n" >>"%s"\n' "$count_file"
printf 'exec "%s" "$@"\n' "$real_awk"
} >"$dir/awk"
chmod +x "$dir/awk"

local -a fixtures=() flags=(--no-parallel --simple)
for file in 1 2 3; do
fixtures[${#fixtures[@]}]="$dir/metadata_${file}_test.sh"
printf 'function test_metadata_case_%s() { assert_same same same; }\n' "$file" \
>"$dir/metadata_${file}_test.sh"
done

local header=true
case "$mode" in
no_header) header=false ;;
list) flags=(--no-parallel --list) ;;
json) flags=(--no-parallel --output json) ;;
parallel_simple) flags=(--parallel --simple) ;;
parallel_header) flags=(--parallel) ;;
esac

local status=0
PATH="$dir:$PATH" BASHUNIT_SHOW_HEADER="$header" ./bashunit --skip-env-file \
"${flags[@]}" "${fixtures[@]}" >"$dir/output" 2>&1 || status=$?

assert_same 0 "$status"
assert_same "$expected" "$(wc -l <"$count_file" | tr -d ' ')"
}

function test_multifile_metadata_scans_with_a_header() {
_assert_multifile_metadata_scan_budget header 6
}

function test_multifile_metadata_scans_without_a_header() {
_assert_multifile_metadata_scan_budget no_header 6
}

function test_multifile_metadata_listing_skips_scans() {
_assert_multifile_metadata_scan_budget list 0
}

function test_multifile_metadata_machine_output_scans() {
_assert_multifile_metadata_scan_budget json 6
}

function test_multifile_metadata_parallel_simple_scans() {
_assert_multifile_metadata_scan_budget parallel_simple 6
}

function test_multifile_metadata_parallel_header_scans() {
_assert_multifile_metadata_scan_budget parallel_header 8
}

function test_run_removes_its_run_output_dir() {
local dir
dir="$(bashunit::temp_dir)"
Expand Down
Loading
Loading