diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a80a35..747c227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and versions are tracked in the repo-root `VERSION` file. - Added portable stdlib temporary file and directory helpers with default exit cleanup. - Added stdlib command path and function introspection helpers. +- Added `std_run_with_timeout` for bounded command execution with macOS/Linux + fallback behavior. ## [0.2.1] - 2026-06-18 diff --git a/lib/bash/std/README.md b/lib/bash/std/README.md index 4966706..527c0d1 100644 --- a/lib/bash/std/README.md +++ b/lib/bash/std/README.md @@ -19,6 +19,8 @@ The library improves Bash-based scripting in a few practical ways: instead of a mysterious non-zero exit. - **Safe command execution**: `std_run` preserves argument boundaries, supports dry-run mode, and can either exit or return a status. +- **Bounded command execution**: `std_run_with_timeout` applies the same command + runner conventions with a timeout. - **Shared dry-run behavior**: scripts do not need to reimplement "print what would happen" logic. - **Composable cleanup**: scripts can register exit cleanup without replacing @@ -205,6 +207,26 @@ in the calling script so the code remains clear. code should use `std_run` to avoid collisions with test frameworks and other Bash libraries that define their own `run` helper. +Use `std_run_with_timeout` when a command must finish within a bounded number of +seconds: + +```bash +std_run_with_timeout 30 curl -fsSL "$health_url" +``` + +It accepts the same initial `--no-exit` and `--quiet` options as `std_run`: + +```bash +if ! std_run_with_timeout --no-exit --quiet 5 nc -z localhost 5432; then + log_warn "database port did not open within 5 seconds" +fi +``` + +Timeouts return status `124`. The helper prefers `timeout` or `gtimeout` when +available and otherwise uses a Bash fallback so scripts work on macOS and Linux. +As with `std_run`, command arguments are executed as an argument array and +dry-run mode logs without running the command. + ## Importing Other Bash Libraries Use `import` to source helper libraries: diff --git a/lib/bash/std/lib_std.sh b/lib/bash/std/lib_std.sh index 30957e7..008bcf0 100644 --- a/lib/bash/std/lib_std.sh +++ b/lib/bash/std/lib_std.sh @@ -30,6 +30,8 @@ # Core helpers: # std_run [--no-exit] [--quiet] cmd ... # # Safe command runner with dry-run & failure handling. +# std_run_with_timeout [opts] seconds cmd ... +# # Safe command runner with a timeout. # exit_if_error rc msg... # Log + exit when rc != 0 (preserves original status). # fatal_error msg... # Convenience wrapper: exit with last status or 1. # std_register_cleanup_hook fn # Run a cleanup function from the shared EXIT trap. @@ -826,6 +828,127 @@ run() { __std_run_impl__ run "$@" } +__std_sleep_interval__() { + if [[ -x /bin/sleep ]]; then + /bin/sleep "$1" + else + sleep "$1" + fi +} + +__std_run_with_timeout_fallback__() { + local timeout_seconds="$1" + shift + local timeout_marker command_pid timer_pid command_status + + timeout_marker="$(mktemp "${TMPDIR:-/tmp}/base-bash-libs-timeout.XXXXXXXXXX" 2>/dev/null)" || return 127 + + "$@" & + command_pid=$! + + ( + __std_sleep_interval__ "$timeout_seconds" + printf '1' > "$timeout_marker" + kill -TERM "$command_pid" 2>/dev/null || true + ) & + timer_pid=$! + + wait "$command_pid" + command_status=$? + + if kill -0 "$timer_pid" 2>/dev/null; then + kill "$timer_pid" 2>/dev/null || true + fi + wait "$timer_pid" 2>/dev/null || true + + if [[ -s "$timeout_marker" ]]; then + command_status=124 + fi + rm -f -- "$timeout_marker" + + return "$command_status" +} + +# +# std_run_with_timeout - Safely executes a command with a timeout. +# +# This helper mirrors `std_run` option handling while bounding the command +# runtime. It prefers `timeout` or `gtimeout` when available and otherwise uses +# a Bash fallback so callers have portable behavior on macOS and Linux. +# +# Usage: +# std_run_with_timeout [--no-exit] [--quiet] command [arg1] ... +# +std_run_with_timeout() { + local exit_on_failure=1 quiet=0 timeout_seconds timeout_path="" exit_code printable_command message + + while (($#)); do + case "${1-}" in + --no-exit) + exit_on_failure=0 + shift + ;; + --quiet) + quiet=1 + shift + ;; + --) + shift + break + ;; + *) + break + ;; + esac + done + + if (($# < 2)); then + log_error "std_run_with_timeout: usage: std_run_with_timeout [--no-exit] [--quiet] command [arg1] ..." + return 1 + fi + + timeout_seconds="$1" + shift + if [[ ! "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then + log_error "std_run_with_timeout: timeout seconds must be a positive integer." + return 1 + fi + + printf -v printable_command "%q " "$@" + printable_command="${printable_command% }" + + if is_dry_run; then + log_info "[DRY-RUN] Would run with ${timeout_seconds}s timeout: ${printable_command}" + return 0 + fi + + if std_command_path timeout_path timeout || std_command_path timeout_path gtimeout; then + "$timeout_path" "$timeout_seconds" "$@" + else + __std_run_with_timeout_fallback__ "$timeout_seconds" "$@" + fi + exit_code=$? + + if ((exit_code)); then + if ((exit_code == 124)); then + message="Command timed out after ${timeout_seconds}s: ${printable_command}" + else + message="Command failed (exit $exit_code): ${printable_command}" + fi + + if ((exit_on_failure)); then + exit_if_error "$exit_code" "$message" + else + if ((! quiet)); then + log_warn "$message (continuing)." + fi + return "$exit_code" + fi + fi + + return 0 +} + ############################################## FILE AND DIRECTORY HANDLING ############################################ # diff --git a/lib/bash/std/tests/lib_std.bats b/lib/bash/std/tests/lib_std.bats index bc0cf89..fa62fe1 100644 --- a/lib/bash/std/tests/lib_std.bats +++ b/lib/bash/std/tests/lib_std.bats @@ -888,6 +888,91 @@ EOF [[ "$output" != *"after"* ]] } +@test "std_run_with_timeout runs commands and preserves arguments" { + local output_file="$TEST_TMPDIR/timeout-output.txt" + + std_run_with_timeout 5 bash -c 'printf "%s\n" "$1" > "$2"' _ "hello world" "$output_file" + + [ "$(cat "$output_file")" = "hello world" ] +} + +@test "std_run_with_timeout --no-exit returns 124 when the command times out" { + local stderr_file="$TEST_TMPDIR/timeout.err" + local rc + + if std_run_with_timeout --no-exit --quiet 1 sleep 2 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 124 ] + [ ! -s "$stderr_file" ] +} + +@test "std_run_with_timeout exits on command failure by default" { + local script="$TEST_TMPDIR/timeout-fail.sh" + + create_script "$script" < "$output_file" +EOF + + bats_run bash "$script" + + [ "$status" -eq 0 ] + [ "$(cat "$output_file")" = "fallback" ] +} + +@test "std_run_with_timeout rejects invalid timeouts" { + local stderr_file="$TEST_TMPDIR/timeout-invalid.err" + local rc + + if std_run_with_timeout --no-exit nope bash -c 'exit 0' 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"std_run_with_timeout: timeout seconds must be a positive integer."* ]] +} + @test "run compatibility wrapper delegates to std_run behavior" { local stderr_file="$TEST_TMPDIR/run-compat.err" local rc