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 @@ -9,6 +9,7 @@
- `--jobs auto` (and `-j auto`) caps parallel concurrency at the detected CPU core count, portable across Linux/macOS/BSD (`nproc`, then `sysctl`, then `getconf`, falling back to 4). The default stays unlimited (`--jobs 0`) (#766)

### Changed
- Faster test execution: removed the remaining `run_test` hot-path forks. Test-label normalization and the per-test clock capture use fork-free return slots (`bashunit::clock::now_to_slot` reads `EPOCHREALTIME` directly on Bash 5.0+), parallel mode is resolved once instead of re-checking env + OS on every `is_enabled` call, and the `no-parallel-tests` opt-out is detected in the existing single-pass file scan rather than a separate per-file `grep`. No behaviour change (#774)
- Faster test execution: removed the remaining per-assertion subshell forks. `assert_equals`/`assert_not_equals` strip ANSI via a return slot (2 fewer forks each on the success path), failure-path label resolution (`assert::label`, `fail`, `handle_bool_assertion_failure`) uses fork-free return slots, and `assert_true`/`assert_false` detect aliases with the `alias` builtin instead of `command -v | grep`. 100x10 `assert_equals` ~1.50s -> ~0.76s on bash 3.2. No behaviour change (#772)
- Faster test execution: removed avoidable subshell forks from the per-test hot path (runtime-error detection, subshell-output decode, name normalization, test-id generation, retry-count lookup, and the passing-test line are now fork-free), and per-test temp-file cleanup no longer forks `rm` when the test created none (`rm` 102 -> 2 on a 100-test file). No behaviour change (#764)
- Per-test execution time now defaults to `auto` (`BASHUNIT_SHOW_EXECUTION_TIME=true|false|auto`): shown when the shell has a fork-free clock (Bash 5.0+, GNU `date`), hidden when measuring would fork an interpreter (e.g. Bash 3.2 on macOS, which falls back to `perl`). This removes ~2 `perl` forks per test on those shells. `--profile`, `--verbose`, reports, and `=true` still measure. See `adrs/adr-008-auto-skip-per-test-timing.md` (#765)
Expand Down
40 changes: 28 additions & 12 deletions src/clock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,44 +89,55 @@ function bashunit::clock::is_expensive() {
esac
}

function bashunit::clock::now() {
_BASHUNIT_CLOCK_NOW_OUT=""

# Return-slot variant of bashunit::clock::now: writes the current time in
# nanoseconds into _BASHUNIT_CLOCK_NOW_OUT. The `shell` branch reads
# EPOCHREALTIME directly (folding in shell_time) so the per-test hot path pays
# no command-substitution fork on Bash 5.0+; `date-seconds` forks once instead
# of twice. Interpreter/`date` branches keep a single internal fork.
# Returns: 0 on success, 1 when no clock implementation is available.
function bashunit::clock::now_to_slot() {
if [ -z "$_BASHUNIT_CLOCK_NOW_IMPL" ]; then
bashunit::clock::_choose_impl || return 1
fi

case "$_BASHUNIT_CLOCK_NOW_IMPL" in
perl)
perl -MTime::HiRes -e 'printf("%.0f\n", Time::HiRes::time() * 1000000000)'
_BASHUNIT_CLOCK_NOW_OUT="$(perl -MTime::HiRes -e 'printf("%.0f\n", Time::HiRes::time() * 1000000000)')"
;;
python)
python - <<'EOF'
_BASHUNIT_CLOCK_NOW_OUT="$(
python - <<'EOF'
import time, sys
sys.stdout.write(str(int(time.time() * 1000000000)))
EOF
)"
;;
node)
node -e 'process.stdout.write((BigInt(Date.now()) * 1000000n).toString())'
_BASHUNIT_CLOCK_NOW_OUT="$(node -e 'process.stdout.write((BigInt(Date.now()) * 1000000n).toString())')"
;;
powershell)
powershell -Command "\
_BASHUNIT_CLOCK_NOW_OUT="$(powershell -Command "\
\$unixEpoch = [DateTime]'1970-01-01 00:00:00';\
\$now = [DateTime]::UtcNow;\
\$ticksSinceEpoch = (\$now - \$unixEpoch).Ticks;\
\$nanosecondsSinceEpoch = \$ticksSinceEpoch * 100;\
Write-Output \$nanosecondsSinceEpoch\
"
")"
;;
date)
date +%s%N
_BASHUNIT_CLOCK_NOW_OUT="$(date +%s%N)"
;;
date-seconds)
local seconds
seconds=$(date +%s)
echo "$((seconds * 1000000000))"
_BASHUNIT_CLOCK_NOW_OUT="$((seconds * 1000000000))"
;;
shell)
# shellcheck disable=SC2155
local shell_time="$(bashunit::clock::shell_time)"
# Read EPOCHREALTIME directly (no shell_time subshell) on the hot path;
# both '.' and ',' decimal separators are handled for locale portability.
local shell_time="${EPOCHREALTIME:-}"
local seconds="${shell_time%%[.,]*}"
local microseconds="${shell_time#*[.,]}"
if [ "$seconds" = "$shell_time" ]; then
Expand All @@ -137,15 +148,20 @@ EOF
microseconds="${microseconds:0:6}"
microseconds="${microseconds#"${microseconds%%[!0]*}"}"
microseconds="${microseconds:-0}"
echo "$(( (seconds * 1000000000) + (microseconds * 1000) ))"
_BASHUNIT_CLOCK_NOW_OUT="$(((seconds * 1000000000) + (microseconds * 1000)))"
;;
*)
bashunit::clock::_choose_impl || return 1
bashunit::clock::now
bashunit::clock::now_to_slot
;;
esac
}

function bashunit::clock::now() {
bashunit::clock::now_to_slot || return 1
echo "$_BASHUNIT_CLOCK_NOW_OUT"
}

function bashunit::clock::shell_time() {
# Get time directly from the shell variable EPOCHREALTIME (Bash 5+)
[ -n "${EPOCHREALTIME+x}" ] && [ -n "$EPOCHREALTIME" ] && LC_ALL=C echo "$EPOCHREALTIME"
Expand Down
13 changes: 13 additions & 0 deletions src/helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,9 @@ _BASHUNIT_PROVIDER_MAP_SCRIPT=""
_BASHUNIT_PROVIDER_MAP_FNS=()
_BASHUNIT_PROVIDER_MAP_PROVIDERS=()
_BASHUNIT_PROVIDER_FN_OUT=""
# Set true when the scanned file carries the "# bashunit: no-parallel-tests"
# opt-out; detected in the same awk pass to avoid a per-file grep fork (#774).
_BASHUNIT_PROVIDER_MAP_NO_PARALLEL=false

#
# Resolves a script path, applying the issue #529 working-dir fallback.
Expand Down Expand Up @@ -377,6 +380,7 @@ function bashunit::helper::build_provider_map() {
_BASHUNIT_PROVIDER_MAP_SCRIPT="$1"
_BASHUNIT_PROVIDER_MAP_FNS=()
_BASHUNIT_PROVIDER_MAP_PROVIDERS=()
_BASHUNIT_PROVIDER_MAP_NO_PARALLEL=false
return
fi

Expand All @@ -387,18 +391,26 @@ function bashunit::helper::build_provider_map() {
_BASHUNIT_PROVIDER_MAP_SCRIPT="$script"
_BASHUNIT_PROVIDER_MAP_FNS=()
_BASHUNIT_PROVIDER_MAP_PROVIDERS=()
_BASHUNIT_PROVIDER_MAP_NO_PARALLEL=false

local count=0
local fn provider
# 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.
while IFS=$'\t' read -r fn provider; do
[ -z "$fn" ] && continue
if [ "$fn" = "@@no_parallel@@" ]; then
[ "$provider" = "1" ] && _BASHUNIT_PROVIDER_MAP_NO_PARALLEL=true
continue
fi
_BASHUNIT_PROVIDER_MAP_FNS[count]="$fn"
_BASHUNIT_PROVIDER_MAP_PROVIDERS[count]="$provider"
count=$((count + 1))
done < <(awk '
/^# bashunit: no-parallel-tests/ { no_parallel = 1; next }
/^[[:space:]]*#[[:space:]]*@?data_provider[[:space:]]+/ {
p = $0
sub(/^[[:space:]]*#[[:space:]]*@?data_provider[[:space:]]+/, "", p)
Expand All @@ -420,6 +432,7 @@ function bashunit::helper::build_provider_map() {
pending = ""
}
}
END { printf "@@no_parallel@@\t%d\n", no_parallel }
' "$script" 2>/dev/null)
}

Expand Down
4 changes: 4 additions & 0 deletions src/main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,10 @@ function bashunit::main::exec_tests() {
trap 'bashunit::main::cleanup' SIGINT
trap '[ $? -eq $EXIT_CODE_STOP_ON_FAILURE ] && bashunit::main::handle_stop_on_failure_sync' EXIT

# Resolve parallel mode once now that --parallel/--no-parallel are parsed, so
# the per-test is_enabled reads a global instead of re-checking env + OS.
bashunit::parallel::resolve_enabled

if bashunit::env::is_parallel_run_enabled && ! bashunit::parallel::is_enabled; then
printf "%sWarning: Parallel tests are supported on macOS, Ubuntu and Windows.\n" "${_BASHUNIT_COLOR_INCOMPLETE}"
printf "For other OS (like Alpine), --parallel is not enabled due to inconsistent results,\n"
Expand Down
37 changes: 30 additions & 7 deletions src/parallel.sh
Original file line number Diff line number Diff line change
Expand Up @@ -143,14 +143,37 @@ function bashunit::parallel::init() {
mkdir -p "$TEMP_DIR_PARALLEL_TEST_SUITE"
}

function bashunit::parallel::is_enabled() {
bashunit::internal_log "bashunit::parallel::is_enabled" \
"requested:$BASHUNIT_PARALLEL_RUN" "os:${_BASHUNIT_OS:-Unknown}"
# Cached result of resolve_enabled ("true"/"false"); empty until resolved.
_BASHUNIT_PARALLEL_ENABLED=""

if bashunit::env::is_parallel_run_enabled &&
# Pure predicate: parallel requested AND running on a supported OS. No caching
# or logging, so it is safe to call fresh (e.g. from unit tests).
function bashunit::parallel::_compute_enabled() {
bashunit::env::is_parallel_run_enabled &&
(bashunit::check_os::is_macos || bashunit::check_os::is_ubuntu ||
bashunit::check_os::is_alpine || bashunit::check_os::is_windows); then
return 0
bashunit::check_os::is_alpine || bashunit::check_os::is_windows)
}

# Resolve parallel mode once (after arg parsing) into _BASHUNIT_PARALLEL_ENABLED
# so is_enabled becomes a pure global read on the per-test hot path. The env +
# OS checks are constant for the whole run.
function bashunit::parallel::resolve_enabled() {
if bashunit::parallel::_compute_enabled; then
_BASHUNIT_PARALLEL_ENABLED=true
else
_BASHUNIT_PARALLEL_ENABLED=false
fi
return 1
bashunit::internal_log "bashunit::parallel::resolve_enabled" \
"requested:$BASHUNIT_PARALLEL_RUN" "os:${_BASHUNIT_OS:-Unknown}" \
"enabled:$_BASHUNIT_PARALLEL_ENABLED"
}

function bashunit::parallel::is_enabled() {
case "$_BASHUNIT_PARALLEL_ENABLED" in
true) return 0 ;;
false) return 1 ;;
esac
# Not resolved yet (e.g. unit tests call is_enabled directly): compute fresh
# without caching so per-test env/OS changes are still honoured.
bashunit::parallel::_compute_enabled
}
28 changes: 16 additions & 12 deletions src/runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -764,20 +764,20 @@ function bashunit::runner::call_test_functions() {

bashunit::helper::check_duplicate_functions "$script" || true

# Check if test file opts out of test-level parallelism
local allow_test_parallel=true
if grep -q "^# bashunit: no-parallel-tests" "$script" 2>/dev/null; then
allow_test_parallel=false
fi

local -a provider_data=()
local provider_data_count=0
local -a parsed_data=()
local parsed_data_count=0

# Scan the file once; per-test provider lookups below are pure-bash (#763).
# The same pass also detects the no-parallel-tests opt-out (#774).
bashunit::helper::build_provider_map "$script"

local allow_test_parallel=true
if [ "$_BASHUNIT_PROVIDER_MAP_NO_PARALLEL" = true ]; then
allow_test_parallel=false
fi

for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do
if bashunit::parallel::is_enabled && bashunit::parallel::must_stop_on_failure; then
break
Expand Down Expand Up @@ -1100,7 +1100,10 @@ function bashunit::runner::run_test() {
# exactly once (on the final attempt) and nothing is double-counted. Each fork
# in --parallel retries itself before writing its single .result file.
while :; do
[ "$measure_duration" = true ] && start_time=$(bashunit::clock::now)
if [ "$measure_duration" = true ]; then
bashunit::clock::now_to_slot
start_time=$_BASHUNIT_CLOCK_NOW_OUT
fi
if bashunit::env::is_test_timeout_enabled; then
bashunit::runner::run_with_timeout "$test_file" "$fn_name" "$@"
test_execution_result="$_BASHUNIT_RUNNER_EXEC_OUT"
Expand Down Expand Up @@ -1129,8 +1132,8 @@ function bashunit::runner::run_test() {

local duration=0
if [ "$measure_duration" = true ]; then
local end_time
end_time=$(bashunit::clock::now)
bashunit::clock::now_to_slot
local end_time=$_BASHUNIT_CLOCK_NOW_OUT
duration=$(((end_time - start_time) / 1000000))
fi

Expand Down Expand Up @@ -1186,15 +1189,16 @@ function bashunit::runner::run_test() {
[ -n "$encoded_hook_message" ] && hook_message="$(bashunit::helper::decode_base64 "$encoded_hook_message")"

bashunit::set_test_title "$test_title"
local label
label="$(bashunit::helper::normalize_test_function_name "$fn_name" "$interpolated_fn_name")"
bashunit::helper::normalize_test_function_name_to_slot "$fn_name" "$interpolated_fn_name"
local label=$_BASHUNIT_HELPER_NORMALIZED_OUT
bashunit::state::reset_test_title
bashunit::state::reset_current_test_interpolated_function_name

local failure_label="$label"
local failure_function="$fn_name"
if [ -n "$hook_failure" ]; then
failure_label="$(bashunit::helper::normalize_test_function_name "$hook_failure")"
bashunit::helper::normalize_test_function_name_to_slot "$hook_failure"
failure_label=$_BASHUNIT_HELPER_NORMALIZED_OUT
failure_function="$hook_failure"
fi

Expand Down
27 changes: 24 additions & 3 deletions tests/unit/clock_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,34 @@ function test_now_on_osx_without_perl() {

mock_macos
bashunit::mock bashunit::dependencies::has_perl mock_false
bashunit::mock bashunit::clock::shell_time <<<"1727708708.326957"
local EPOCHREALTIME="1727708708.326957"
bashunit::mock bashunit::dependencies::has_python mock_false
bashunit::mock bashunit::dependencies::has_node mock_false

assert_same "1727708708326957000" "$(bashunit::clock::now)"
}

function test_now_to_slot_shell_branch_computes_from_epochrealtime() {
local EPOCHREALTIME="1727708708.326957"

bashunit::clock::now_to_slot

assert_same "1727708708326957000" "$_BASHUNIT_CLOCK_NOW_OUT"
}

function test_now_to_slot_date_seconds_branch() {
mock_unknown_linux_os
bashunit::mock perl mock_non_existing_fn
bashunit::mock bashunit::dependencies::has_python mock_false
bashunit::mock bashunit::dependencies::has_node mock_false
bashunit::mock bashunit::clock::shell_time mock_non_existing_fn
bashunit::mock date 'mock_date_seconds "$@"'

bashunit::clock::now_to_slot

assert_same "1727768951000000000" "$_BASHUNIT_CLOCK_NOW_OUT"
}

function test_runtime_in_milliseconds_when_not_empty_time() {
bashunit::mock perl <<<"1720705883457"
bashunit::mock bashunit::dependencies::has_python mock_false
Expand All @@ -116,7 +137,7 @@ function test_runtime_in_milliseconds_when_not_empty_time() {
}

function test_now_prefers_shell_time_over_perl() {
bashunit::mock bashunit::clock::shell_time <<<"1234.567890"
local EPOCHREALTIME="1234.567890"
bashunit::mock perl <<<"999999999999"
bashunit::mock bashunit::dependencies::has_python mock_false
bashunit::mock bashunit::dependencies::has_node mock_false
Expand All @@ -125,7 +146,7 @@ function test_now_prefers_shell_time_over_perl() {
}

function test_now_handles_shell_time_with_comma_decimal_separator() {
bashunit::mock bashunit::clock::shell_time <<<"1234,567890"
local EPOCHREALTIME="1234,567890"

assert_same "1234567890000" "$(bashunit::clock::now)"
}
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/console_results_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,9 @@ function test_print_hook_completed_output_milliseconds() {
local original_parallel_run=$BASHUNIT_PARALLEL_RUN
export BASHUNIT_SIMPLE_OUTPUT=false
export BASHUNIT_PARALLEL_RUN=false
# Recompute is_enabled from this test's env, not a cache inherited from a
# --parallel meta-run of the suite.
_BASHUNIT_PARALLEL_ENABLED=""
export TERMINAL_WIDTH=80

local output
Expand All @@ -685,6 +688,9 @@ function test_print_hook_completed_output_seconds() {
local original_parallel_run=$BASHUNIT_PARALLEL_RUN
export BASHUNIT_SIMPLE_OUTPUT=false
export BASHUNIT_PARALLEL_RUN=false
# Recompute is_enabled from this test's env, not a cache inherited from a
# --parallel meta-run of the suite.
_BASHUNIT_PARALLEL_ENABLED=""
export TERMINAL_WIDTH=80

local output
Expand All @@ -701,6 +707,9 @@ function test_print_hook_completed_output_minutes() {
local original_parallel_run=$BASHUNIT_PARALLEL_RUN
export BASHUNIT_SIMPLE_OUTPUT=false
export BASHUNIT_PARALLEL_RUN=false
# Recompute is_enabled from this test's env, not a cache inherited from a
# --parallel meta-run of the suite.
_BASHUNIT_PARALLEL_ENABLED=""
export TERMINAL_WIDTH=80

local output
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/helpers_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,28 @@ function test_provider_map_returns_empty_for_unreadable_script() {
"$(provider_for "/no/such/path/nope_test.sh" "test_with_at_annotation")"
}

function test_build_provider_map_flags_no_parallel_marker() {
local file
file="$(mktemp)"
printf '#!/usr/bin/env bash\n# bashunit: no-parallel-tests\nfunction test_foo() { :; }\n' >"$file"

bashunit::helper::build_provider_map "$file"

assert_same "true" "$_BASHUNIT_PROVIDER_MAP_NO_PARALLEL"
rm -f "$file"
}

function test_build_provider_map_no_parallel_marker_defaults_false() {
local file
file="$(mktemp)"
printf '#!/usr/bin/env bash\nfunction test_foo() { :; }\n' >"$file"

bashunit::helper::build_provider_map "$file"

assert_same "false" "$_BASHUNIT_PROVIDER_MAP_NO_PARALLEL"
rm -f "$file"
}

function test_left_trim() {
assert_same "foo" "$(bashunit::helper::trim " foo")"
}
Expand Down
Loading
Loading