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
Empty file added .RAN_TEARDOWN
Empty file.
Empty file added .setup
Empty file.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Ctrl-C runs `tear_down_after_script` for the file it interrupts in a sequential run, so a file-scoped resource is released. A second Ctrl-C now ends the run even if that hook never returns (#1323)
- A test killed by `--test-timeout` runs its `tear_down`, so a per-test resource is released. Best effort within the watchdog's grace before it sends SIGKILL, so a hook cannot outlive the timeout it cleans up after (#1324)
- A test file that fails to source sweeps its script temp files, so a `bashunit::temp_file` it created at top level no longer survives the run. `bashunit bench` already did this (#1325)
- A malformed `@timeout` or `@retry` runs `tear_down_after_script` before it aborts the run, so the file releases what `set_up_before_script` acquired. Sequential and `--parallel` both leaked it (#1329)

## [0.50.0](https://github.com/TypedDevs/bashunit/compare/0.49.0...0.50.0) - 2026-08-18

Expand Down
10 changes: 7 additions & 3 deletions src/helper/annotations.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,15 @@ function bashunit::helper::_annotations_is_count() {
}

##
# Aborts the run when a marker carries a value the runner cannot honour.
# Returns non-zero when a marker carries a value the runner cannot honour.
# Falling back to the default silently would run a different test than the one
# the annotation asked for, the same reasoning as @revs=abc in #884.
#
# Reports rather than exits, so the caller can finish the file it is in before
# aborting: exiting from here skipped tear_down_after_script (#1329).
# Arguments: $1 - script the map was built from
##
function bashunit::helper::annotations_validate_or_exit() {
function bashunit::helper::annotations_validate() {
local script=$1
local i=0
local total=${#_BASHUNIT_ANNOT_MAP_FNS[@]}
Expand All @@ -102,11 +105,13 @@ function bashunit::helper::annotations_validate_or_exit() {
value="${_BASHUNIT_ANNOT_MAP_TIMEOUTS[i]}"
if [ -n "$value" ] && ! bashunit::helper::_annotations_is_count "$value"; then
bashunit::helper::_annotations_reject "$script" "$fn" "timeout" "$value"
return 1
fi

value="${_BASHUNIT_ANNOT_MAP_RETRIES[i]}"
if [ -n "$value" ] && ! bashunit::helper::_annotations_is_count "$value"; then
bashunit::helper::_annotations_reject "$script" "$fn" "retry" "$value"
return 1
fi

i=$((i + 1))
Expand All @@ -119,5 +124,4 @@ function bashunit::helper::annotations_validate_or_exit() {
function bashunit::helper::_annotations_reject() {
printf "%sError: @%s '%s' above %s in %s is not a non-negative integer.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "$3" "$4" "$2" "$1" "${_BASHUNIT_COLOR_DEFAULT}" >&2
exit 1
}
19 changes: 19 additions & 0 deletions src/runner/discovery.sh
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,9 @@ function bashunit::runner::load_test_files() {
# the subshell and the run reports "All tests passed" over a file where one
# of two same-named tests never ran (#1147).
bashunit::helper::check_duplicate_functions "$test_file" || true
# Non-zero only when a malformed annotation aborted the file before any test
# ran; the loop settles the file below and then honours the abort (#1329).
local file_status=0
if bashunit::parallel::is_enabled; then
bashunit::runner::wait_for_job_slot
# Capture rather than discard: a worker's stderr cannot be written
Expand All @@ -242,14 +245,23 @@ function bashunit::runner::load_test_files() {
# call_test_functions waits for its own per-test workers before it
# returns, which is what makes this ordering hold.
{
# An aborting annotation returns non-zero here, and this shell runs with
# errexit off, so the hook below still runs. The status is dropped: the
# worker has no channel to abort the parent with, which is a separate
# exit-code defect and not this teardown leak (#1329).
bashunit::runner::call_test_functions "$test_file" "$_cached_fns"
bashunit::runner::run_tear_down_after_script "$test_file"
# A hook failure recorded in here dies with the subshell (#1147), so
# publish it the way a test publishes its result.
bashunit::runner::publish_file_hook_failure "$?" "$test_file"
} 2>"$_worker_stderr" &
else
# Read after, never `call_test_functions || file_status=$?`: a command on
# the left of `||` runs with errexit ignored, and bash carries that down
# into every function and subshell it calls. The `set -e` that aborts a
# failing set_up stopped firing, so a broken hook passed on bash 5.
bashunit::runner::call_test_functions "$test_file" "$_cached_fns"
file_status=$?
bashunit::runner::run_tear_down_after_script "$test_file"
fi
# Sequential ran the hook just above; under --parallel the worker owns it.
Expand All @@ -262,6 +274,13 @@ function bashunit::runner::load_test_files() {
fi
bashunit::internal_log "Finished file" "$test_file"
bashunit::runner::restore_workdir
# A malformed annotation still aborts the whole run, as it has to: a value
# the runner cannot honour would otherwise run a different test than the one
# asked for (#884). It aborts from here rather than from inside
# call_test_functions so the file's teardown runs first (#1329).
if [ "$file_status" -ne 0 ]; then
exit "$file_status"
fi
done

# A listing dispatched no worker, so there is nothing to wait for and no
Expand Down
5 changes: 3 additions & 2 deletions src/runner/exec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,9 @@ function bashunit::runner::call_test_functions() {
# per-test @timeout/@retry/@skip annotations (#1020).
bashunit::helper::build_provider_map "$script"
# Before anything runs: a value the runner cannot honour would otherwise run
# a different test than the annotation asked for.
bashunit::helper::annotations_validate_or_exit "$script"
# a different test than the annotation asked for. Reported up to the caller,
# which owns the file's teardown, rather than exited from here (#1329).
bashunit::helper::annotations_validate "$script" || return 1

local allow_test_parallel=true
if [ "$_BASHUNIT_PROVIDER_MAP_NO_PARALLEL" = true ]; then
Expand Down
57 changes: 57 additions & 0 deletions tests/acceptance/bashunit_annotations_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,60 @@ function test_annotations_combine_with_tags() {

assert_contains "Test timed out after 1s" "$output"
}

function run_bad_annotation_fixture() { # $1 = annotation line, $2 = parallel flag
local dir fixture
dir="$(bashunit::temp_dir annotation_cleanup)"
fixture="$dir/bad_annotation_test.sh"
BAD_ANNOTATION_MARKER="$dir/resource"
{
printf 'RESOURCE=""\n'
printf 'function set_up_before_script() {\n'
printf ' RESOURCE="$CLEANUP_MARKER"\n'
printf ' : >"$RESOURCE"\n'
printf '}\n'
printf 'function tear_down_after_script() {\n'
printf ' rm -f "$RESOURCE"\n'
printf '}\n'
printf '%s\n' "$1"
printf 'function test_bad_annotation() { assert_same "ok" "ok"; }\n'
} >"$fixture"

BAD_ANNOTATION_EC=0
BAD_ANNOTATION_OUTPUT=$(CLEANUP_MARKER="$BAD_ANNOTATION_MARKER" NO_COLOR=1 \
./bashunit "$2" --env "$TEST_ENV_FILE" "$fixture" 2>&1) || BAD_ANNOTATION_EC=$?
}

# A malformed annotation aborts from inside call_test_functions, which used to
# take the file's tear_down_after_script down with it (#1329).
function test_a_malformed_timeout_runs_the_file_teardown() {
run_bad_annotation_fixture '# @timeout abc' --no-parallel

assert_general_error "" "" "$BAD_ANNOTATION_EC"
assert_contains "@timeout 'abc'" "$BAD_ANNOTATION_OUTPUT"
assert_file_not_exists "$BAD_ANNOTATION_MARKER"
}

function test_a_malformed_timeout_runs_the_file_teardown_under_parallel() {
run_bad_annotation_fixture '# @timeout abc' --parallel

assert_general_error "" "" "$BAD_ANNOTATION_EC"
assert_contains "@timeout 'abc'" "$BAD_ANNOTATION_OUTPUT"
assert_file_not_exists "$BAD_ANNOTATION_MARKER"
}

function test_a_malformed_retry_runs_the_file_teardown() {
run_bad_annotation_fixture '# @retry abc' --no-parallel

assert_general_error "" "" "$BAD_ANNOTATION_EC"
assert_contains "@retry 'abc'" "$BAD_ANNOTATION_OUTPUT"
assert_file_not_exists "$BAD_ANNOTATION_MARKER"
}

function test_a_malformed_retry_runs_the_file_teardown_under_parallel() {
run_bad_annotation_fixture '# @retry abc' --parallel

assert_general_error "" "" "$BAD_ANNOTATION_EC"
assert_contains "@retry 'abc'" "$BAD_ANNOTATION_OUTPUT"
assert_file_not_exists "$BAD_ANNOTATION_MARKER"
}
Loading