Skip to content

Commit dedb77b

Browse files
authored
feat(runner): add per-test timeout to abort hanging tests (#725)
1 parent 2eb39cf commit dedb77b

10 files changed

Lines changed: 325 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
## Unreleased
44

5+
### Added
6+
- `--test-timeout <seconds>` flag and `BASHUNIT_TEST_TIMEOUT` env var: abort an individual test that runs longer than the given number of seconds, report it as a failure and keep running the rest. Disabled by default (`0`); needs no external `timeout` command and works on Bash 3.2+ (#721)
7+
58
### Fixed
9+
- A test that exited non-zero no longer poisons the exit code of subsequent tests in the same file (the per-test exit code was accumulated instead of reset)
610
- Coverage report now counts backslash line-continuation lines as covered: a multi-line statement's hit is propagated forward across its continuation chain, so the lines after a trailing `\` are no longer reported as uncovered (#722)
711

812
### Changed

docs/command-line.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ bashunit test tests/ --parallel --simple
7575
| `-s, --simple` | Simple output (dots) |
7676
| `--detailed` | Detailed output (default) |
7777
| `-S, --stop-on-failure` | Stop on first failure |
78+
| `--test-timeout <seconds>` | Fail a test if it runs longer than N seconds |
7879
| `--show-skipped` | Show skipped tests summary at end |
7980
| `--show-incomplete` | Show incomplete tests summary at end |
8081
| `-vvv, --verbose` | Show execution details |
@@ -431,6 +432,34 @@ BASHUNIT_PROFILE_COUNT=3 bashunit test tests/ --profile
431432
```
432433
:::
433434

435+
### Test Timeout
436+
437+
> `bashunit test --test-timeout <seconds>`
438+
439+
Abort an individual test if it runs longer than the given number of seconds and
440+
report it as a failure, then continue with the remaining tests. This protects a
441+
run from hanging forever on a blocked test — for example a mock that was never
442+
given an implementation and waits on input that never arrives.
443+
444+
The timeout is **disabled by default** (`0`). It applies per test (set up and
445+
tear down included) and is expressed in whole seconds. It needs no external
446+
`timeout` command and works on Bash 3.2+ (including the default macOS Bash).
447+
448+
::: code-group
449+
```bash [Example]
450+
bashunit test tests/ --test-timeout 5
451+
```
452+
```[Output]
453+
✗ Error: Test hangs forever
454+
Test timed out after 5s
455+
456+
Tests: 1 passed, 1 failed, 2 total
457+
```
458+
:::
459+
460+
It can also be set via the `BASHUNIT_TEST_TIMEOUT` environment variable (see
461+
[configuration](/configuration#test-timeout)).
462+
434463
### No Progress
435464

436465
> `bashunit test --no-progress`

docs/configuration.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,30 @@ By default, when an assertion fails within a test, subsequent assertions in the
127127
The `--stop-on-failure` flag is separate – it stops the entire test runner after a failing **test**, while assertion-level stopping happens within each test.
128128
:::
129129

130+
## Test timeout
131+
132+
> `BASHUNIT_TEST_TIMEOUT=<seconds>`
133+
134+
Abort an individual test if it runs longer than the given number of seconds,
135+
report it as a failure and keep running the remaining tests. `0` (disabled) by
136+
default. Useful to stop a run from hanging forever on a blocked test, such as a
137+
mock left without an implementation.
138+
139+
The value is expressed in whole seconds and applies per test (set up and tear
140+
down included). It needs no external `timeout` command and works on Bash 3.2+,
141+
including the default macOS Bash.
142+
143+
Similar as using `--test-timeout` option on the [command line](/command-line#test-timeout).
144+
145+
::: code-group
146+
```bash [Enable a 5s timeout]
147+
BASHUNIT_TEST_TIMEOUT=5
148+
```
149+
```bash [Disabled (default)]
150+
BASHUNIT_TEST_TIMEOUT=0
151+
```
152+
:::
153+
130154
## Stop on assertion failure
131155

132156
> `BASHUNIT_STOP_ON_ASSERTION_FAILURE=true|false`

src/console_header.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ Options:
118118
--output <format> Output format: tap (TAP version 13)
119119
-R, --run-all Run all assertions (don't stop on first failure)
120120
-S, --stop-on-failure Stop on first failure
121+
--test-timeout <seconds> Fail a test if it runs longer than N seconds (0 = off)
121122
-vvv, --verbose Show execution details
122123
--debug [file] Enable shell debug mode
123124
--no-output Suppress all output

src/env.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ _BASHUNIT_DEFAULT_OUTPUT_FORMAT=""
115115
_BASHUNIT_DEFAULT_FAIL_ON_RISKY="false"
116116
_BASHUNIT_DEFAULT_PROFILE="false"
117117
_BASHUNIT_DEFAULT_PROFILE_COUNT="10"
118+
# Per-test timeout in seconds (0 = disabled)
119+
_BASHUNIT_DEFAULT_TEST_TIMEOUT="0"
118120

119121
: "${BASHUNIT_PARALLEL_RUN:=${PARALLEL_RUN:=$_BASHUNIT_DEFAULT_PARALLEL_RUN}}"
120122
: "${BASHUNIT_PARALLEL_JOBS:=0}"
@@ -140,6 +142,7 @@ _BASHUNIT_DEFAULT_PROFILE_COUNT="10"
140142
: "${BASHUNIT_FAIL_ON_RISKY:=${FAIL_ON_RISKY:=$_BASHUNIT_DEFAULT_FAIL_ON_RISKY}}"
141143
: "${BASHUNIT_PROFILE:=${PROFILE:=$_BASHUNIT_DEFAULT_PROFILE}}"
142144
: "${BASHUNIT_PROFILE_COUNT:=${PROFILE_COUNT:=$_BASHUNIT_DEFAULT_PROFILE_COUNT}}"
145+
: "${BASHUNIT_TEST_TIMEOUT:=${TEST_TIMEOUT:=$_BASHUNIT_DEFAULT_TEST_TIMEOUT}}"
143146
# Support NO_COLOR standard (https://no-color.org)
144147
if [ -n "${NO_COLOR:-}" ]; then
145148
BASHUNIT_NO_COLOR="true"
@@ -151,6 +154,24 @@ function bashunit::env::is_parallel_run_enabled() {
151154
[ "$BASHUNIT_PARALLEL_RUN" = "true" ]
152155
}
153156

157+
##
158+
# Whether a per-test timeout is configured (a positive integer number of seconds).
159+
# Returns: 0 when enabled, 1 otherwise.
160+
##
161+
function bashunit::env::is_test_timeout_enabled() {
162+
case "${BASHUNIT_TEST_TIMEOUT:-0}" in
163+
'' | *[!0-9]*) return 1 ;;
164+
esac
165+
[ "${BASHUNIT_TEST_TIMEOUT:-0}" -gt 0 ]
166+
}
167+
168+
##
169+
# Prints the configured per-test timeout in seconds (0 when disabled).
170+
##
171+
function bashunit::env::test_timeout_secs() {
172+
printf '%s' "${BASHUNIT_TEST_TIMEOUT:-0}"
173+
}
174+
154175
function bashunit::env::is_show_header_enabled() {
155176
[ "$BASHUNIT_SHOW_HEADER" = "true" ]
156177
}

src/main.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ function bashunit::main::cmd_test() {
7474
--no-parallel)
7575
export BASHUNIT_PARALLEL_RUN=false
7676
;;
77+
--test-timeout)
78+
export BASHUNIT_TEST_TIMEOUT="$2"
79+
shift
80+
;;
7781
-w | --watch)
7882
export BASHUNIT_WATCH_MODE=true
7983
;;

src/runner.sh

Lines changed: 159 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,147 @@ function bashunit::runner::render_running_file_header() {
843843
fi
844844
}
845845

846+
# Result slots for the timeout-aware execution path (see run_with_timeout).
847+
_BASHUNIT_RUNNER_EXEC_OUT=""
848+
_BASHUNIT_RUNNER_TIMED_OUT="false"
849+
850+
##
851+
# Runs a single test inside the capture subshell: sets up the EXIT trap that
852+
# encodes assertion counts/exit code, runs set_up, applies the shell mode and
853+
# finally invokes the test function. Meant to be called from a subshell (either
854+
# the `$(...)` capture or a backgrounded job), so its `set`/`trap`/`exit` calls
855+
# stay isolated. Emits the test stdout (with stderr merged) followed by the
856+
# encoded context from cleanup_on_exit.
857+
# Arguments: $1 test file, $2 function name, $@ test args
858+
##
859+
function bashunit::runner::execute_test_body() {
860+
local test_file=$1
861+
shift
862+
local fn_name=$1
863+
shift
864+
865+
# Save subshell stdout to FD 5 so the EXIT trap can restore it.
866+
# When set -e kills the subshell during a redirected block in
867+
# execute_test_hook, the redirect leaks into the EXIT trap,
868+
# causing export_subshell_context output to be lost.
869+
exec 5>&1
870+
# shellcheck disable=SC2064
871+
trap "exit_code=\$?; bashunit::runner::cleanup_on_exit \"$test_file\" \"\$exit_code\"" EXIT
872+
bashunit::state::initialize_assertions_count
873+
874+
if bashunit::env::is_login_shell_enabled; then
875+
bashunit::runner::source_login_shell_profiles
876+
fi
877+
878+
# Enable coverage tracking early to include set_up/tear_down hooks
879+
if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then
880+
bashunit::coverage::enable_trap
881+
fi
882+
883+
# Run set_up and capture exit code without || to preserve errexit behavior
884+
# shellcheck disable=SC2030
885+
_BASHUNIT_SETUP_COMPLETED=false
886+
local setup_exit_code=0
887+
bashunit::runner::run_set_up "$test_file"
888+
setup_exit_code=$?
889+
_BASHUNIT_SETUP_COMPLETED=true
890+
if [ $setup_exit_code -ne 0 ]; then
891+
exit $setup_exit_code
892+
fi
893+
894+
# Apply shell mode setting for test execution
895+
if bashunit::env::is_strict_mode_enabled; then
896+
set -eu
897+
# Bash 3.0 ships a broken pipefail; only enable it where it is reliable.
898+
if bashunit::runner::_supports_reliable_pipefail; then
899+
set -o pipefail
900+
else
901+
set +o pipefail
902+
fi
903+
else
904+
set +euo pipefail
905+
fi
906+
907+
# 2>&1: Redirects the std-error (FD 2) to the std-output (FD 1).
908+
# points to the original std-output.
909+
"$fn_name" "$@" 2>&1
910+
}
911+
912+
##
913+
# Prints an encoded subshell result for a test that timed out: empty assertion
914+
# counters and exit code 124 (the conventional "timed out" code, already mapped
915+
# by classify_kill_signal). The empty TEST_HOOK_MESSAGE/TITLE/OUTPUT fields would
916+
# base64-encode to an empty string anyway, so the line is emitted directly rather
917+
# than mutating the shared _BASHUNIT_* globals (it mirrors the layout produced by
918+
# bashunit::state::export_subshell_context). Bash 3.0+ compatible.
919+
##
920+
function bashunit::runner::build_timeout_result() {
921+
printf '%s' "##ASSERTIONS_FAILED=0##ASSERTIONS_PASSED=0##ASSERTIONS_SKIPPED=0\
922+
##ASSERTIONS_INCOMPLETE=0##ASSERTIONS_SNAPSHOT=0##TEST_EXIT_CODE=124\
923+
##TEST_HOOK_FAILURE=##TEST_HOOK_MESSAGE=##TEST_TITLE=##TEST_OUTPUT=##"
924+
}
925+
926+
##
927+
# Runs the test body with a watchdog that kills it after BASHUNIT_TEST_TIMEOUT
928+
# seconds. The body runs as a backgrounded job in its own process group (set -m)
929+
# so the watchdog can SIGTERM/SIGKILL the whole tree — a hanging test usually
930+
# blocks in a child process, which signalling the subshell alone cannot reach.
931+
# Writes the captured result to _BASHUNIT_RUNNER_EXEC_OUT and "true"/"false" to
932+
# _BASHUNIT_RUNNER_TIMED_OUT. Bash 3.0+ compatible (validated on Bash 3.2).
933+
# Arguments: $1 test file, $2 function name, $@ test args
934+
##
935+
function bashunit::runner::run_with_timeout() {
936+
local test_file=$1
937+
shift
938+
local fn_name=$1
939+
shift
940+
local secs
941+
secs=$(bashunit::env::test_timeout_secs)
942+
943+
# NOTE: these must NOT use bashunit::temp_file — that prefixes the current
944+
# test id, and cleanup_on_exit (run inside the test subshell) would unlink
945+
# them via cleanup_testcase_temp_files before we read them back here.
946+
local tmp_dir="${BASHUNIT_TEMP_DIR:-${TMPDIR:-/tmp}}"
947+
local out_file marker_file
948+
out_file="$("$MKTEMP" "$tmp_dir/bashunit_timeout_out.XXXXXXX")"
949+
marker_file="$("$MKTEMP" "$tmp_dir/bashunit_timeout_marker.XXXXXXX")"
950+
rm -f "$marker_file"
951+
952+
# Both jobs run in their own process group (set -m) so each can be killed as a
953+
# whole tree. The body MUST run in an explicit ( ) subshell: a backgrounded { }
954+
# group does not run its EXIT trap on normal completion, which would drop the
955+
# encoded assertion context. The watchdog's fds are detached from the caller so
956+
# a lingering `sleep` can never hold a captured stdout pipe open.
957+
set -m
958+
(bashunit::runner::execute_test_body "$test_file" "$fn_name" "$@") >"$out_file" 2>&1 &
959+
local test_pid=$!
960+
(
961+
sleep "$secs"
962+
: >"$marker_file"
963+
kill -TERM -"$test_pid" 2>/dev/null
964+
sleep 0.3
965+
kill -KILL -"$test_pid" 2>/dev/null
966+
) </dev/null >/dev/null 2>&1 &
967+
local watchdog_pid=$!
968+
set +m
969+
970+
wait "$test_pid" 2>/dev/null
971+
# Tear down the watchdog group (its `sleep` child included) so it cannot fire
972+
# late or block the caller.
973+
kill -TERM -"$watchdog_pid" 2>/dev/null
974+
wait "$watchdog_pid" 2>/dev/null
975+
976+
if [ -f "$marker_file" ]; then
977+
_BASHUNIT_RUNNER_TIMED_OUT="true"
978+
_BASHUNIT_RUNNER_EXEC_OUT="$(bashunit::runner::build_timeout_result)"
979+
else
980+
_BASHUNIT_RUNNER_TIMED_OUT="false"
981+
_BASHUNIT_RUNNER_EXEC_OUT="$(cat "$out_file" 2>/dev/null)"
982+
fi
983+
984+
rm -f "$out_file" "$marker_file"
985+
}
986+
846987
function bashunit::runner::run_test() {
847988
local start_time
848989
start_time=$(bashunit::clock::now)
@@ -868,54 +1009,15 @@ function bashunit::runner::run_test() {
8681009
# This means that FD 3 now points to wherever the std-output was pointing.
8691010
exec 3>&1
8701011

871-
local test_execution_result=$(
872-
# Save subshell stdout to FD 5 so the EXIT trap can restore it.
873-
# When set -e kills the subshell during a redirected block in
874-
# execute_test_hook, the redirect leaks into the EXIT trap,
875-
# causing export_subshell_context output to be lost.
876-
exec 5>&1
877-
# shellcheck disable=SC2064
878-
trap "exit_code=\$?; bashunit::runner::cleanup_on_exit \"$test_file\" \"\$exit_code\"" EXIT
879-
bashunit::state::initialize_assertions_count
880-
881-
if bashunit::env::is_login_shell_enabled; then
882-
bashunit::runner::source_login_shell_profiles
883-
fi
884-
885-
# Enable coverage tracking early to include set_up/tear_down hooks
886-
if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then
887-
bashunit::coverage::enable_trap
888-
fi
889-
890-
# Run set_up and capture exit code without || to preserve errexit behavior
891-
# shellcheck disable=SC2030
892-
_BASHUNIT_SETUP_COMPLETED=false
893-
local setup_exit_code=0
894-
bashunit::runner::run_set_up "$test_file"
895-
setup_exit_code=$?
896-
_BASHUNIT_SETUP_COMPLETED=true
897-
if [ $setup_exit_code -ne 0 ]; then
898-
exit $setup_exit_code
899-
fi
900-
901-
# Apply shell mode setting for test execution
902-
if bashunit::env::is_strict_mode_enabled; then
903-
set -eu
904-
# Bash 3.0 ships a broken pipefail; only enable it where it is reliable.
905-
if bashunit::runner::_supports_reliable_pipefail; then
906-
set -o pipefail
907-
else
908-
set +o pipefail
909-
fi
910-
else
911-
set +euo pipefail
912-
fi
913-
914-
# 2>&1: Redirects the std-error (FD 2) to the std-output (FD 1).
915-
# points to the original std-output.
916-
"$fn_name" "$@" 2>&1
917-
918-
)
1012+
local test_execution_result
1013+
local timed_out="false"
1014+
if bashunit::env::is_test_timeout_enabled; then
1015+
bashunit::runner::run_with_timeout "$test_file" "$fn_name" "$@"
1016+
test_execution_result="$_BASHUNIT_RUNNER_EXEC_OUT"
1017+
timed_out="$_BASHUNIT_RUNNER_TIMED_OUT"
1018+
else
1019+
test_execution_result=$(bashunit::runner::execute_test_body "$test_file" "$fn_name" "$@")
1020+
fi
9191021

9201022
# Closes FD 3, which was used temporarily to hold the original stdout.
9211023
exec 3>&-
@@ -950,6 +1052,10 @@ function bashunit::runner::run_test() {
9501052
local runtime_error
9511053
runtime_error=$(bashunit::runner::detect_runtime_error "$runtime_output")
9521054

1055+
# parse_result accumulates _BASHUNIT_TEST_EXIT_CODE; reset it so each test's
1056+
# exit code is read in isolation (a non-zero/timed-out test must not poison
1057+
# the next one).
1058+
_BASHUNIT_TEST_EXIT_CODE=0
9531059
bashunit::runner::parse_result "$fn_name" "$test_execution_result" "$@"
9541060

9551061
local test_exit_code="$_BASHUNIT_TEST_EXIT_CODE"
@@ -1003,6 +1109,11 @@ function bashunit::runner::run_test() {
10031109
fi
10041110
fi
10051111

1112+
# A test that exceeded BASHUNIT_TEST_TIMEOUT gets a clear, specific message.
1113+
if [ "$timed_out" = "true" ]; then
1114+
error_message="Test timed out after $(bashunit::env::test_timeout_secs)s"
1115+
fi
1116+
10061117
bashunit::console_results::print_error_test "$failure_function" "$error_message" "$runtime_output"
10071118
bashunit::reports::add_test_failed "$test_file" "$failure_label" "$duration" "$total_assertions" "$error_message"
10081119
bashunit::runner::write_failure_result_output "$test_file" "$failure_function" "$error_message" "$runtime_output"

0 commit comments

Comments
 (0)