From 87f852b330c8aeb5d77bb0f608f2fe44c63f305d Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 21 Aug 2026 12:29:14 +0200 Subject: [PATCH 1/2] fix(runner): run the file teardown before a bad annotation aborts A malformed @timeout or @retry aborted the run with `exit 1` from inside `call_test_functions`. `set_up_before_script` had already run, so whatever it acquired was leaked: the file's `tear_down_after_script` never got a frame. `annotations_validate` now reports instead of exiting, `call_test_functions` returns non-zero, and the caller that owns the file's teardown runs it before honouring the abort. Same shape as the bench runner's fix for #1322. The error text and the non-zero exit code are unchanged. Closes #1329 --- CHANGELOG.md | 1 + src/helper/annotations.sh | 10 +++- src/runner/discovery.sh | 19 ++++++- src/runner/exec.sh | 5 +- tests/acceptance/bashunit_annotations_test.sh | 57 +++++++++++++++++++ 5 files changed, 85 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 676ae441..322d8c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/helper/annotations.sh b/src/helper/annotations.sh index 93882058..b3e70502 100644 --- a/src/helper/annotations.sh +++ b/src/helper/annotations.sh @@ -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[@]} @@ -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)) @@ -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 } diff --git a/src/runner/discovery.sh b/src/runner/discovery.sh index 2e17b0eb..14e879e4 100644 --- a/src/runner/discovery.sh +++ b/src/runner/discovery.sh @@ -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 @@ -242,14 +245,19 @@ 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. { - bashunit::runner::call_test_functions "$test_file" "$_cached_fns" + # Swallowed on purpose: an aborting annotation must not skip the hook + # below. Reporting the abort to the parent is a separate defect -- a + # malformed annotation next to a passing file exits 0 -- left alone here + # so this stays the teardown fix it says it is (#1329). + bashunit::runner::call_test_functions "$test_file" "$_cached_fns" || true 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 - bashunit::runner::call_test_functions "$test_file" "$_cached_fns" + 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. @@ -262,6 +270,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 diff --git a/src/runner/exec.sh b/src/runner/exec.sh index 910e08aa..ef0f7803 100644 --- a/src/runner/exec.sh +++ b/src/runner/exec.sh @@ -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 diff --git a/tests/acceptance/bashunit_annotations_test.sh b/tests/acceptance/bashunit_annotations_test.sh index e5650aae..d0b50152 100644 --- a/tests/acceptance/bashunit_annotations_test.sh +++ b/tests/acceptance/bashunit_annotations_test.sh @@ -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" +} From 3346691168a6504dbbebd5d611883c2efc80ea5b Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 21 Aug 2026 12:46:49 +0200 Subject: [PATCH 2/2] fix(runner): read the abort status without suppressing errexit `call_test_functions ... || file_status=$?` put the whole test run in a context where bash ignores errexit, and bash carries that down into every function and subshell the command calls. The `set -e` that aborts a failing `set_up` stopped firing, so a broken hook reported as passing: three acceptance tests failed on every Linux job while passing on bash 3.2. Read `$?` on the next line instead. This shell runs with errexit off, so a non-zero return does not abort it and the file's teardown still runs. --- .RAN_TEARDOWN | 0 .setup | 0 src/runner/discovery.sh | 18 +++++++++++------- 3 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 .RAN_TEARDOWN create mode 100644 .setup diff --git a/.RAN_TEARDOWN b/.RAN_TEARDOWN new file mode 100644 index 00000000..e69de29b diff --git a/.setup b/.setup new file mode 100644 index 00000000..e69de29b diff --git a/src/runner/discovery.sh b/src/runner/discovery.sh index 14e879e4..f213fddc 100644 --- a/src/runner/discovery.sh +++ b/src/runner/discovery.sh @@ -245,19 +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. { - # Swallowed on purpose: an aborting annotation must not skip the hook - # below. Reporting the abort to the parent is a separate defect -- a - # malformed annotation next to a passing file exits 0 -- left alone here - # so this stays the teardown fix it says it is (#1329). - bashunit::runner::call_test_functions "$test_file" "$_cached_fns" || true + # 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 - bashunit::runner::call_test_functions "$test_file" "$_cached_fns" || - file_status=$? + # 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.