From 6fe135cfd4aec0ba2a3f58597a57cad89b9f0b31 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Sun, 28 Jun 2026 20:34:16 -0700 Subject: [PATCH] Enhance std_run execution policy --- CHANGELOG.md | 8 ++ README.md | 4 +- examples/cookbook-cleanup-temp.sh | 2 +- lib/bash/std/README.md | 58 +++++---- lib/bash/std/lib_std.sh | 191 +++++++++++++++++++++++------- lib/bash/std/tests/lib_std.bats | 112 +++++++++++++++++- 6 files changed, 304 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6c699..cddae0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,20 @@ and versions are tracked in the repo-root `VERSION` file. ## [Unreleased] +### Added + +- Added `std_run --timeout`, `--max-attempts`, and `--retry-delay` execution + policy options for timeout-only, retry-only, and timeout-plus-retry command + execution. + ### Changed - Changed string case and trim helpers to mutate named variables in place instead of requiring command substitution. - Added public `assert_variable_name` validation for helpers that accept Bash variable names. +- Deprecated `std_run_with_timeout` in documentation for new code; it remains as + a compatibility wrapper around `std_run --timeout`. ## [1.0.0] - 2026-06-21 diff --git a/README.md b/README.md index f6ee542..f847385 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,8 @@ base_bash_libs_require_version 1.1.0 Small standalone script that sources the stdlib, imports the file helpers, logs progress, and runs a checked command. - [`examples/cookbook-cleanup-temp.sh`](examples/cookbook-cleanup-temp.sh) - Cleanup hooks, temp paths, version checks, command resolution, and timeout - command execution. + Cleanup hooks, temp paths, version checks, command resolution, timeout, and + checked command execution. - [`examples/cookbook-args-lists-strings.sh`](examples/cookbook-args-lists-strings.sh) Argument parsing, list helpers, and in-place string transformations working together. diff --git a/examples/cookbook-cleanup-temp.sh b/examples/cookbook-cleanup-temp.sh index 4909517..2675fac 100755 --- a/examples/cookbook-cleanup-temp.sh +++ b/examples/cookbook-cleanup-temp.sh @@ -20,7 +20,7 @@ cleanup_marker() { std_register_cleanup_hook cleanup_marker printf 'workspace=%s\n' "$workspace_dir" >"$report_file" -std_run_with_timeout --no-exit --quiet 5 test -s "$report_file" +std_run --no-exit --quiet --timeout 5 test -s "$report_file" printf_path="" if std_command_path printf_path printf; then diff --git a/lib/bash/std/README.md b/lib/bash/std/README.md index 03d2533..0f0578c 100644 --- a/lib/bash/std/README.md +++ b/lib/bash/std/README.md @@ -18,9 +18,7 @@ The library improves Bash-based scripting in a few practical ways: - **Readable failures**: fatal errors include a message and Bash stack trace 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. + dry-run mode, timeout, retry, and can either exit or return a status. - **Shared dry-run behavior**: scripts do not need to reimplement "print what would happen" logic. - **Composable cleanup**: scripts can register exit cleanup without replacing @@ -170,7 +168,7 @@ itself is fine and the user simply gave invalid arguments. ## Running Commands Safely -`std_run` is the preferred helper for simple external command execution: +`std_run` is the preferred helper for external command execution: ```bash std_run git status --short @@ -182,6 +180,8 @@ It improves on ad hoc command strings because it: - executes commands as argument arrays, not through `eval` - preserves spaces and special characters - logs a copy-pastable command in dry-run mode +- can bound each attempt with `--timeout` +- can retry transient failures with `--max-attempts` and `--retry-delay` - exits through `exit_if_error` by default when a command fails Dry-run mode: @@ -212,33 +212,51 @@ if ! std_run --no-exit --quiet test -f "$optional_file"; then fi ``` -Use `std_run` for commands plus arguments. Keep shell features such as -pipelines, redirection, process substitution, and complex conditionals explicit -in the calling script so the code remains clear. - -`run` remains available as a compatibility wrapper for existing callers, but new -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 +Add a per-attempt timeout when a command must finish within a bounded number of seconds: ```bash -std_run_with_timeout 30 curl -fsSL "$health_url" +std_run --timeout 30 curl -fsSL "$health_url" ``` -It accepts the same initial `--no-exit` and `--quiet` options as `std_run`: +Timeouts return status `124` when the caller uses `--no-exit`: ```bash -if ! std_run_with_timeout --no-exit --quiet 5 nc -z localhost 5432; then +if ! std_run --no-exit --quiet --timeout 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. +Retry transient failures by setting the total attempt count. `--retry-delay` +adds a fixed sleep between failed attempts: + +```bash +std_run --max-attempts 3 --retry-delay 2 curl -fsSL "$artifact_url" +``` + +Timeout and retry compose directly. The timeout is per attempt, not a total +budget for all attempts: + +```bash +std_run --timeout 30 --max-attempts 3 --retry-delay 2 curl -fsSL "$artifact_url" +``` + +`--retry-attempts` is accepted as an alias for `--max-attempts`, but new code +should prefer `--max-attempts` because it makes clear that the value is the total +number of attempts, not retries after the first attempt. + +Use `std_run` for commands plus arguments. Keep shell features such as +pipelines, redirection, process substitution, and complex conditionals explicit +in the calling script so the code remains clear. + +`run` remains available as a compatibility wrapper for existing callers, but new +code should use `std_run` to avoid collisions with test frameworks and other +Bash libraries that define their own `run` helper. + +`std_run_with_timeout` remains available as a compatibility wrapper, but new +code should use `std_run --timeout N ...`. Timeout execution prefers `timeout` +or `gtimeout` when available and otherwise uses a Bash fallback so scripts work +on macOS and Linux. ## Importing Other Bash Libraries diff --git a/lib/bash/std/lib_std.sh b/lib/bash/std/lib_std.sh index 96e9d87..266c963 100644 --- a/lib/bash/std/lib_std.sh +++ b/lib/bash/std/lib_std.sh @@ -28,10 +28,10 @@ # __SCRIPT_DIR__ Absolute path to the script that sourced the library. # # Core helpers: -# std_run [--no-exit] [--quiet] cmd ... -# # Safe command runner with dry-run & failure handling. +# std_run [opts] cmd ... +# # Safe command runner with dry-run, timeout, retry & failure handling. # std_run_with_timeout [opts] seconds cmd ... -# # Safe command runner with a timeout. +# # Compatibility wrapper for std_run --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. @@ -51,6 +51,10 @@ # # Patterns: # std_run some_cmd # exits on failure; DRY_RUN=true/1/yes/on prints instead. +# std_run --timeout 30 some_cmd +# # bounds the command attempt to 30 seconds. +# std_run --max-attempts 3 --retry-delay 2 some_cmd +# # retries transient failures. # some_cmd || fatal_error ... # preserves failing exit code before terminating. # add_to_path -p "/opt/tools" # inject directories without duplicates. # @@ -769,6 +773,59 @@ is_dry_run() { return 1 } +__std_is_positive_integer__() { + [[ "${1-}" =~ ^[1-9][0-9]*$ ]] +} + +__std_is_non_negative_integer__() { + [[ "${1-}" =~ ^[0-9]+$ ]] +} + +__std_join_run_policy__() { + local result_name="$1" timeout_seconds="$2" max_attempts="$3" retry_delay="$4" + local policies=() + local policy joined_policy="" + + [[ -n "$timeout_seconds" ]] && policies+=("${timeout_seconds}s timeout") + ((max_attempts > 1)) && policies+=("${max_attempts} attempts") + ((retry_delay > 0)) && policies+=("${retry_delay}s retry delay") + + for policy in "${policies[@]}"; do + if [[ -n "$joined_policy" ]]; then + joined_policy+=", " + fi + joined_policy+="$policy" + done + + printf -v "$result_name" '%s' "$joined_policy" +} + +__std_run_once__() { + local timeout_seconds="$1" + shift + local timeout_path="" + + if [[ -n "$timeout_seconds" ]]; then + 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 + else + "$@" + fi +} + +__std_run_status_message__() { + local result_name="$1" exit_code="$2" timeout_seconds="$3" printable_command="$4" + + if ((exit_code == 124)) && [[ -n "$timeout_seconds" ]]; then + printf -v "$result_name" 'Command timed out after %ss: %s' "$timeout_seconds" "$printable_command" + else + printf -v "$result_name" 'Command failed (exit %s): %s' "$exit_code" "$printable_command" + fi +} + # # std_run - Safely executes a simple command with its arguments. # @@ -781,6 +838,9 @@ is_dry_run() { # - Argument Safe: Correctly handles spaces and special characters in arguments. # - Dry-Run Mode: If the global variable DRY_RUN (or dry_run) is truthy, it # prints the command instead of running it. +# - Optional Timeout: `--timeout N` bounds each command attempt to N seconds. +# - Optional Retry: `--max-attempts N` retries failed commands up to N total +# attempts, optionally sleeping `--retry-delay N` seconds between attempts. # - Exit on Failure: By default, it will exit the script if the command # returns a non-zero exit code. # - Optional No-Exit: If an initial argument is `--no-exit`, the function @@ -798,6 +858,14 @@ is_dry_run() { # command's original exit code. # --quiet If provided as an initial argument with `--no-exit`, suppress # the warning normally logged when the command fails. +# --timeout N +# Bound each command attempt to N seconds. +# --max-attempts N +# Try the command up to N total times. Defaults to 1. +# --retry-attempts N +# Alias for --max-attempts. +# --retry-delay N +# Sleep N seconds between failed attempts. Defaults to 0. # # Examples: # # Run a simple command. Exits if `ls` fails. @@ -819,7 +887,7 @@ is_dry_run() { __std_run_impl__() { local helper_name="$1" shift - local exit_on_failure=1 quiet=0 + local exit_on_failure=1 quiet=0 timeout_seconds="" max_attempts=1 retry_delay=0 # Parse optional run flags before the command. while (($#)); do @@ -832,6 +900,33 @@ __std_run_impl__() { quiet=1 shift ;; + --timeout) + shift + if (($# == 0)) || ! __std_is_positive_integer__ "${1-}"; then + log_error "$helper_name: timeout seconds must be a positive integer." + return 1 + fi + timeout_seconds="$1" + shift + ;; + --max-attempts | --retry-attempts) + shift + if (($# == 0)) || ! __std_is_positive_integer__ "${1-}"; then + log_error "$helper_name: max attempts must be a positive integer." + return 1 + fi + max_attempts="$1" + shift + ;; + --retry-delay) + shift + if (($# == 0)) || ! __std_is_non_negative_integer__ "${1-}"; then + log_error "$helper_name: retry delay seconds must be a non-negative integer." + return 1 + fi + retry_delay="$1" + shift + ;; --) shift break @@ -854,10 +949,17 @@ __std_run_impl__() { # --- Dry-Run Handling --- if is_dry_run; then + local policy_description + __std_join_run_policy__ policy_description "$timeout_seconds" "$max_attempts" "$retry_delay" + # Use printf with the %q format specifier. This is the safest way to # print a command and its arguments in a way that is unambiguous and # could be copied and pasted back into a shell. - log_info "[DRY-RUN] Would run: ${printable_command}" + if [[ -n "$policy_description" ]]; then + log_info "[DRY-RUN] Would run with ${policy_description}: ${printable_command}" + else + log_info "[DRY-RUN] Would run: ${printable_command}" + fi return 0 fi @@ -865,14 +967,42 @@ __std_run_impl__() { # Execute the command. Using "$@" is the key. It expands each argument # as a separate, quoted string, preserving spaces and special characters. # This is the safe, modern alternative to using `eval`. - "$@" - local exit_code=$? + local attempt=1 exit_code=0 message + while ((attempt <= max_attempts)); do + if __std_run_once__ "$timeout_seconds" "$@"; then + return 0 + else + exit_code=$? + fi + + if ((attempt < max_attempts)); then + if ((! quiet)); then + __std_run_status_message__ message "$exit_code" "$timeout_seconds" "$printable_command" + log_warn "${message} (attempt ${attempt} of ${max_attempts}; retrying)." + fi + if ((retry_delay > 0)); then + __std_sleep_interval__ "$retry_delay" + fi + fi + + attempt=$((attempt + 1)) + done + if ((exit_code)); then + if ((max_attempts > 1)); then + if ((exit_code == 124)) && [[ -n "$timeout_seconds" ]]; then + message="Command timed out after ${timeout_seconds}s on final attempt (${max_attempts} attempts): ${printable_command}" + else + message="Command failed after ${max_attempts} attempts (exit ${exit_code}): ${printable_command}" + fi + else + __std_run_status_message__ message "$exit_code" "$timeout_seconds" "$printable_command" + fi if ((exit_on_failure)); then - exit_if_error "$exit_code" "Command failed (exit $exit_code): ${printable_command}" + exit_if_error "$exit_code" "$message" else if ((! quiet)); then - log_warn "Command failed (exit $exit_code): ${printable_command} (continuing)." + log_warn "$message (continuing)." fi return $exit_code fi @@ -941,16 +1071,17 @@ __std_run_with_timeout_fallback__() { # 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 + local timeout_seconds + local run_options=() while (($#)); do case "${1-}" in --no-exit) - exit_on_failure=0 + run_options+=("--no-exit") shift ;; --quiet) - quiet=1 + run_options+=("--quiet") shift ;; --) @@ -970,44 +1101,12 @@ std_run_with_timeout() { timeout_seconds="$1" shift - if [[ ! "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then + if ! __std_is_positive_integer__ "$timeout_seconds"; 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 + __std_run_impl__ std_run_with_timeout "${run_options[@]}" --timeout "$timeout_seconds" -- "$@" } ############################################## FILE AND DIRECTORY HANDLING ############################################ diff --git a/lib/bash/std/tests/lib_std.bats b/lib/bash/std/tests/lib_std.bats index b2ac7b1..f909b99 100644 --- a/lib/bash/std/tests/lib_std.bats +++ b/lib/bash/std/tests/lib_std.bats @@ -928,6 +928,110 @@ EOF [[ "$output" != *"after"* ]] } +@test "std_run --timeout returns 124 when the command times out" { + local stderr_file="$TEST_TMPDIR/run-timeout.err" + local rc + + if std_run --no-exit --quiet --timeout 1 sleep 2 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 124 ] + [ ! -s "$stderr_file" ] +} + +@test "std_run --max-attempts retries until the command succeeds" { + local counter_file="$TEST_TMPDIR/retry-count.txt" + local script="$TEST_TMPDIR/retry-eventual-success.sh" + + create_script "$script" <<'EOF' +#!/usr/bin/env bash +count=0 +[[ -f "$1" ]] && count="$(cat "$1")" +count=$((count + 1)) +printf '%s\n' "$count" > "$1" +((count >= 3)) +EOF + + std_run --no-exit --quiet --max-attempts 3 bash "$script" "$counter_file" + + [ "$?" -eq 0 ] + [ "$(cat "$counter_file")" = "3" ] +} + +@test "std_run combines per-attempt timeout with retry" { + local counter_file="$TEST_TMPDIR/timeout-retry-count.txt" + local output_file="$TEST_TMPDIR/timeout-retry-output.txt" + local script="$TEST_TMPDIR/timeout-retry.sh" + + create_script "$script" <<'EOF' +#!/usr/bin/env bash +count=0 +[[ -f "$1" ]] && count="$(cat "$1")" +count=$((count + 1)) +printf '%s\n' "$count" > "$1" +if ((count == 1)); then + sleep 2 +else + printf 'ok\n' > "$2" +fi +EOF + + std_run --no-exit --quiet --timeout 1 --max-attempts 2 bash "$script" "$counter_file" "$output_file" + + [ "$?" -eq 0 ] + [ "$(cat "$counter_file")" = "2" ] + [ "$(cat "$output_file")" = "ok" ] +} + +@test "std_run rejects invalid execution policy options" { + local stderr_file="$TEST_TMPDIR/run-policy-invalid.err" + local rc + + if std_run --timeout 0 true 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"std_run: timeout seconds must be a positive integer."* ]] + + : > "$stderr_file" + if std_run --max-attempts 0 true 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"std_run: max attempts must be a positive integer."* ]] + + : > "$stderr_file" + if std_run --retry-delay nope true 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"std_run: retry delay seconds must be a non-negative integer."* ]] +} + +@test "std_run dry-run reports timeout and retry policy without executing" { + local target="$TEST_TMPDIR/dry-run-policy.txt" + local stderr_file="$TEST_TMPDIR/dry-run-policy.err" + + DRY_RUN=true + + std_run --timeout 30 --max-attempts 3 --retry-delay 2 touch "$target" 2>"$stderr_file" + + [ "$?" -eq 0 ] + [ ! -e "$target" ] + [[ "$(cat "$stderr_file")" == *"30s timeout"* ]] + [[ "$(cat "$stderr_file")" == *"3 attempts"* ]] + [[ "$(cat "$stderr_file")" == *"2s retry delay"* ]] +} + @test "std_run_with_timeout runs commands and preserves arguments" { local output_file="$TEST_TMPDIR/timeout-output.txt" @@ -1365,8 +1469,12 @@ EOF sample_introspection_function() { return 0; } std_function_exists sample_introspection_function - ! std_function_exists "$missing_name" - ! std_function_exists "not-valid" + if std_function_exists "$missing_name"; then + return 1 + fi + if std_function_exists "not-valid"; then + return 1 + fi } @test "assert_function_exists accepts defined functions and exits for missing ones" {