diff --git a/CHANGELOG.md b/CHANGELOG.md index eb5e3be2..774b69cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - `--stop-on-failure` runs `tear_down_after_script` for the file it halts in, so a sequential run releases what `set_up_before_script` acquired before the halt (#1321) - `bashunit bench` runs `tear_down_after_script` before it aborts on a malformed annotation, so the file releases what `set_up_before_script` acquired (#1322) - 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) ## [0.50.0](https://github.com/TypedDevs/bashunit/compare/0.49.0...0.50.0) - 2026-08-18 diff --git a/src/runner/exec.sh b/src/runner/exec.sh index 6784df14..910e08aa 100644 --- a/src/runner/exec.sh +++ b/src/runner/exec.sh @@ -257,6 +257,16 @@ function bashunit::runner::execute_test_body() { _BASHUNIT_RUNNER_EXIT_FILE=$test_file # shellcheck disable=SC2154 # assigned inside the trap body, read by cleanup_on_exit (runner/hooks.sh) trap 'exit_code=$?; bashunit::runner::cleanup_on_exit "$_BASHUNIT_RUNNER_EXIT_FILE" "$exit_code"' EXIT + # A timed-out test is killed by the watchdog's group SIGTERM (run_with_timeout). + # Bash runs no EXIT trap for a fatal signal it does not have a trap for, so + # tear_down was skipped and whatever set_up acquired for that test was leaked + # (#1324). Trapping TERM and exiting hands control to the EXIT trap above, which + # is where tear_down lives. 143 is the conventional 128+SIGTERM. + # + # Best effort on purpose: the watchdog SIGKILLs the group shortly after, so a + # tear_down slower than that grace is cut off. A hook that never returns must + # not outlive the timeout it is cleaning up after. + trap 'exit 143' TERM bashunit::state::initialize_assertions_count if bashunit::env::is_login_shell_enabled; then diff --git a/tests/acceptance/bashunit_timeout_test.sh b/tests/acceptance/bashunit_timeout_test.sh index ba3b7c5c..cf074470 100644 --- a/tests/acceptance/bashunit_timeout_test.sh +++ b/tests/acceptance/bashunit_timeout_test.sh @@ -92,3 +92,30 @@ TEST assert_contains "Running" "$output" assert_less_than 10 "$((end - start))" } + +# A timed-out test was killed without running tear_down, so whatever set_up had +# acquired for it was leaked (#1324). The file-scoped hook already survived, +# because the runner loop carries on to the next file. +# +# A 30s sleep against a 1s budget, so the test is still running when the budget +# expires however loaded the runner is. #1093 is the other direction of the same +# care: never assert on a fast test with a budget it could cross. +function test_bashunit_runs_tear_down_for_a_timed_out_test() { + local dir fixture marker + dir="$(bashunit::temp_dir timeout_teardown)" + fixture="$dir/hanging_test.sh" + marker="$dir/marker" + { + printf 'function set_up() { : >"$TIMEOUT_MARKER.setup"; }\n' + printf 'function tear_down() { : >"$TIMEOUT_MARKER.teardown"; }\n' + printf 'function test_hangs() { sleep 30; assert_true true; }\n' + } >"$fixture" + + local output + output="$(TIMEOUT_MARKER="$marker" ./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + --test-timeout 1 "$fixture")" || true + + assert_contains "Test timed out after 1s" "$output" + assert_file_exists "$marker.setup" + assert_file_exists "$marker.teardown" +}